Rate Limiting
Algorithms, distributed implementations, and production patterns.
Why Rate Limiting
| Goal | Example |
|---|---|
| DDoS protection | Block 10k req/s from single IP |
| Cost control | Prevent runaway API billing |
| Fairness | Stop one tenant from starving others |
| SLA enforcement | Guarantee 99th percentile latency |
Algorithms
1. Fixed Window Counter
Divide time into fixed windows (e.g., 1-minute buckets). Count requests per window. Reject when count exceeds limit.
Burst problem: A client can send 2x the limit across a window boundary.
Window 1 [00:00–01:00] Window 2 [01:00–02:00]
|████████████ 100 req|████████████ 100 req|
^
200 req in 2 seconds around boundary
key = "ratelimit:{user}:{minute}"
INCR key
EXPIRE key 60
Simple but flawed for burst-sensitive APIs.
2. Sliding Window Log
Store timestamp of every request. On each request:
- Remove timestamps older than
now - window - Count remaining entries
- Reject if count >= limit, else add
now
Accurate — no boundary burst. Memory heavy — stores every timestamp per user.
At 1000 req/min × 1M users = 1B entries in memory.
3. Sliding Window Counter (Hybrid)
Approximate sliding window using two fixed-window counters.
prev_window_count = 80
curr_window_count = 30
elapsed_in_current = 0.4 # 40% into current window
estimated = curr_window_count + prev_window_count * (1 - elapsed_in_current)
= 30 + 80 * 0.6
= 78
Good enough: error rate < 0.1% under real traffic distributions. Memory: O(1) per user.
4. Token Bucket
- Bucket holds up to
capacitytokens - Tokens refill at fixed
rate(tokens/sec) - Each request consumes 1 token; reject if bucket empty
- Burst-friendly: allows short bursts up to
capacity
AWS API Gateway uses token bucket — burst is bucket size, rate is refill rate.
5. Leaky Bucket
Requests enter a queue; drain at fixed rate regardless of input rate.
- Smooths bursty traffic into uniform output
- Queue full → drop request
- Used by nginx
limit_req
Differences from token bucket: token bucket allows bursts through; leaky bucket shapes them.
Algorithm Comparison
graph TD
T[Burst Traffic Arrives]
T --> FW[Fixed Window]
T --> SWL[Sliding Window Log]
T --> SWC[Sliding Window Counter]
T --> TB[Token Bucket]
T --> LB[Leaky Bucket]
FW --> FW1[Passes burst at boundary - 2x spike possible]
SWL --> SWL1[Accurate block - high memory cost]
SWC --> SWC1[Approximate block - O1 memory]
TB --> TB1[Passes burst up to capacity - then throttles]
LB --> LB1[Queues burst - uniform output rate]
Redis Implementations
Sliding Window Counter (Lua)
Atomic read-update-expiry in a single script — no race conditions.
-- KEYS[1] = current window key, KEYS[2] = prev window key
-- ARGV[1] = limit, ARGV[2] = elapsed_fraction (0.0–1.0), ARGV[3] = window_seconds
local curr = tonumber(redis.call('GET', KEYS[1])) or 0
local prev = tonumber(redis.call('GET', KEYS[2])) or 0
local limit = tonumber(ARGV[1])
local weight = 1 - tonumber(ARGV[2])
local estimated = curr + prev * weight
if estimated >= limit then
return 0 -- rejected
end
redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]) * 2)
return 1 -- allowed
window = int(time.time() // 60)
curr_key = f"ratelimit:{user_id}:{window}"
prev_key = f"ratelimit:{user_id}:{window - 1}"
elapsed = (time.time() % 60) / 60 # fraction into current window
allowed = redis.evalsha(sha, 2, curr_key, prev_key, LIMIT, elapsed, 60)
Token Bucket (MULTI/EXEC)
def is_allowed(redis, user_id, capacity, rate):
key = f"tokenbucket:{user_id}"
now = time.time()
with redis.pipeline() as pipe:
while True:
try:
pipe.watch(key)
data = pipe.hgetall(key)
tokens = float(data.get(b'tokens', capacity))
last_ref = float(data.get(b'last', now))
# refill
elapsed = now - last_ref
tokens = min(capacity, tokens + elapsed * rate)
if tokens < 1:
pipe.unwatch()
return False
pipe.multi()
pipe.hset(key, mapping={'tokens': tokens - 1, 'last': now})
pipe.expire(key, int(capacity / rate) + 10)
pipe.execute()
return True
except redis.WatchError:
continue # retry on concurrent modification
Distributed Rate Limiting
The Problem
Client ──► API Server 1 ──┐
Client ──► API Server 2 ──┼──► Each server has local counter
Client ──► API Server 3 ──┘ No coordination = 3x the limit passes
Solution: Central Redis Counter
Client ──► API Server 1 ──┐
Client ──► API Server 2 ──┼──► Redis Cluster (central counter)
Client ──► API Server 3 ──┘
Race condition without atomicity:
Server1: GET tokens → 1
Server2: GET tokens → 1 # both see 1, both allow
Server1: SET tokens 0
Server2: SET tokens 0 # 2 requests pass instead of 1
Fix: use Lua scripts (atomic) or Redis INCR + TTL pattern.
Redis Cluster Sharding
Route each rate limit key to a consistent shard:
hash_slot = CRC16("ratelimit:{user_id}") % 16384
{} in the key forces Redis Cluster to hash only the bracketed part — all keys for a user land on the same slot, enabling Lua scripts across those keys.
Rate Limit Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1719640800
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1719640800
| Header | Value |
|---|---|
X-RateLimit-Limit |
Max requests in window |
X-RateLimit-Remaining |
Requests left in current window |
X-RateLimit-Reset |
Unix timestamp when window resets |
Retry-After |
Seconds until client may retry (RFC 7231) |
nginx Rate Limiting
http {
# define shared memory zone: 10MB stores ~160k IPs
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
# burst=20: allow queue of 20 extra requests
# nodelay: serve burst immediately (no artificial delay)
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
}
}
}
Without nodelay, nginx delays burst requests to smooth them to the rate — effectively a leaky bucket. With nodelay, burst requests pass immediately (token bucket behavior).
AWS API Gateway Throttling
Account limit: 10,000 req/s (hard ceiling across all APIs)
Stage limit: 1,000 req/s (per deployment stage)
Method limit: 100 req/s (per route override)
Usage Plans — attach to API keys for per-customer limits:
Usage Plan "free-tier":
rate: 10 req/s
burst: 50 # token bucket capacity
quota: 10,000/day
rate= token refill rateburst= bucket capacity (short spike allowed)quota= daily hard limit
Exceeds rate/burst → 429 Too Many Requests
Exceeds quota → 429 Limit Exceeded
Rate Limiting Strategies
| Strategy | Key | Use Case |
|---|---|---|
| Per-user | ratelimit:user:{user_id} |
Authenticated API |
| Per-IP | ratelimit:ip:{ip} |
Public endpoints, unauthenticated |
| Per-API-key | ratelimit:key:{api_key} |
B2B / partner APIs |
| Per-endpoint | ratelimit:{user}:{route} |
Expensive endpoints (search, export) |
| Global | ratelimit:global |
System-wide DDoS protection |
Combine: per-IP at edge (nginx/CDN) + per-user in app layer + per-endpoint for expensive routes.
Graceful Degradation
Reject vs Queue
| Reject (fail fast) | Queue | |
|---|---|---|
| Latency | Low | Higher |
| Client UX | Gets 429 immediately | May wait and succeed |
| Server load | Bounded | Can grow unbounded |
| Use case | Stateless API | Background jobs, webhooks |
Priority Queues for Premium Users
┌─── Premium queue (capacity: 500) ──► Workers
Incoming requests ──┤
└─── Standard queue (capacity: 100) ──► Workers
def enqueue(request):
plan = get_user_plan(request.user_id)
queue = "queue:premium" if plan == "premium" else "queue:standard"
if redis.llen(queue) >= QUEUE_LIMITS[plan]:
return 429 # queue full, reject
redis.rpush(queue, serialize(request))
return 202 # accepted
Workers drain premium queue first; fall through to standard when idle.
Shedding Strategy
load < 70% → allow all
load 70–90% → drop standard tier, allow premium
load > 90% → drop all non-critical, return 503
Quick Reference
| Algorithm | Memory | Burst | Accuracy | Best For |
|---|---|---|---|---|
| Fixed window | O(1) | Yes (boundary) | Low | Simple counters |
| Sliding window log | O(n) | No | Exact | Audit logs |
| Sliding window counter | O(1) | Partial | ~99.9% | General API limiting |
| Token bucket | O(1) | Yes (controlled) | High | API gateways |
| Leaky bucket | O(queue) | No (smoothed) | High | Traffic shaping |