Overview
First, let's define what a rate limiter is. It's a system that controls the rate at which requests are processed. Ok, simple enough. But how does this work in a distributed system? Each service cannot have its own logic for rate limiting as it would cause issues with consistency.
So that means we need a single source of truth for rate limiting. For this example we will use Redis as our rate limiter. More specifically we will create a python implementation that interfaces with Redis using INCR and EXPIRE commands to implement a token bucket.
Token Bucket
Token bucket is a rate limiting algorithm that allows a fixed number of requests per second. It works by maintaining a bucket of tokens, and each request consumes a token. If the bucket is empty, the request is rejected.
$$ \text{tokens}(t) = \min\left(\text{capacity}, \text{tokens}(t_0) + \text{rate} \times (t - t_0)\right) $$
Where:
- $\text{tokens}(t)$ = available tokens at time $t$
- $\text{capacity}$ = maximum bucket size
- $\text{rate}$ = token refill rate (tokens per second)
- $t_0$ = last refill time
A request is allowed if:
$$ \text{tokens}(t) \geq \text{cost} $$
Simple Implementation
class SimpleLimiter:
def __init__(self, capacity, fill_rate):
self.capacity = capacity
self.fill_rate = fill_rate
self.tokens = capacity
self.last_timestamp = time.time()
def allow_request(self, tokens=1):
now = time.time()
# 1. Refill tokens based on time passed
seconds_passed = now - self.last_timestamp
self.tokens += seconds_passed * self.fill_rate
# 2. Cap at capacity
if self.tokens > self.capacity:
self.tokens = self.capacity
self.last_timestamp = now
# 3. Check if we have enough
if self.tokens >= tokens:
self.tokens -= tokens
return True
return False
The Problem
This fails immediately in a multi-threaded environment (like Flask or Django). Two threads could read self.tokens at the exact same time (e.g., value 5), both subtract 1, and write back 4. But it should be 3. This is a Race Condition.
Thread Safety
In order to deal with this race condition we have to make sure that the allow_request method is thread-safe. In Python this can be achieved using a Lock (Mutex).
class SimpleLimiter:
def __init__(self, capacity, fill_rate):
self.capacity = capacity
self.fill_rate = fill_rate
self.tokens = capacity
self.last_timestamp = time.time()
self.lock = threading.Lock() # Mutex
def allow_request(self, tokens=1):
# Acquire the lock
with self.lock:
now = time.time()
seconds_passed = now - self.last_timestamp
self.tokens += seconds_passed * self.fill_rate
if self.tokens > self.capacity:
self.tokens = self.capacity
self.last_timestamp = now
if self.tokens >= tokens:
self.tokens -= tokens
return True
return False
Excellent! Now we have a thread-safe implementation, this will work great... on one server.
Scenario:
User Amakes a request to Server 1, they get a tokenUser Amakes a request to Server 2. Server 2 has no idea thatUser Aalready got a token from Server 1. All it knows is that this user as far as its concerned has 1 token left. So it allows the request.
User A effectively now has Limit * Number of Servers tokens.
This is where we need a distributed rate limiter.
Distributed State
class DistributedRateLimiter:
def __init__(self, redis_client, key, capacity, fill_rate):
self.redis = redis_client
self.key = key
self.capacity = capacity
self.fill_rate = fill_rate
self.last_timestamp_key = f"{key}:ts"
def allow_request(self, tokens_needed=1):
"""
Lua script to atomicaly:
1. Refill tokens based on time passed.
2. Check if enough tokens exist.
3. Decrement and update timestamp.
"""
lua_script = """
local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
local capacity = tonumber(ARGV[1])
local fill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
-- Get current state
local last_tokens = tonumber(redis.call("get", tokens_key)) or capacity
local last_refill = tonumber(redis.call("get", timestamp_key)) or now
-- Refill tokens
local delta = math.max(0, now - last_refill)
local filled_tokens = math.min(capacity, last_tokens + (delta * fill_rate))
-- Check if allowed
if filled_tokens >= requested then
local new_tokens = filled_tokens - requested
redis.call("set", tokens_key, new_tokens)
redis.call("set", timestamp_key, now)
return 1 -- Allowed
end
return 0 -- Denied
"""
cmd = self.redis.register_script(lua_script)
return bool(cmd(
keys=[self.key, self.last_timestamp_key],
args=[self.capacity, self.fill_rate, time.time(), tokens_needed]
))
Notice how we are not using Lock. This is because the Lua script is atomic. It will execute in a single step and no other thread can access the state in between.
What's Next
The token bucket is just one approach. Other rate limiting algorithms worth exploring:
- Sliding Window Log — tracks exact timestamps of each request, more precise but uses more memory
- Sliding Window Counter — hybrid of fixed window and sliding log, good balance of accuracy and efficiency
- Leaky Bucket — smooths out burst traffic by processing requests at a fixed rate