API gateway and rate limiting
A gateway checks every request before a service sees it. The rate limit counts per client in Redis, with one atomic script, and refuses with 429.
- 1The gateway runs cheap checks first: authenticate, rate limit, quota, then route.
- 2Count in Redis with one atomic script per request, so every gateway copy shares one limit.
- 3Token bucket allows bursts; sliding windows are exact; a fixed window lets twice the limit through at a boundary.
- 4Refuse with 429 and Retry-After. Decide in advance whether to fail open or closed when Redis is down.
The gateway authenticates, rate-limits and routes, so every service does not repeat that work. It is stateless; the counts live in Redis.
| where | runs on | status |
|---|---|---|
| Edge or CDN, by IP | Edge, WAF | IP floods |
| Gateway, by client, count in Redis | Redis | Approved |
| Each service, in memory only | Service | One instance |
| Client SDK throttles itself | Client | Courtesy only |
| Bounded pool or queue at the database | Service | Last line |
| No limit; scale out on load | Service | Not approved |
- Key the limit by the authenticated client, not by IP. Many users share one NAT address.
- Limit per route too. A search costs more than a profile read.
- A client SDK can be changed or skipped, so the server must enforce the limit.
I limit by IP at the edge, by client in the gateway with Redis, and I keep a bounded pool in front of the database.
HTTP/1.1 429 Too Many Requests1
Retry-After: 8 // whole seconds, rounded up2
client, on 429:
wait Retry-After + a random jitter3
THEN retry once; on a second 429, wait longer- 1The status for a rate limit. Use 503 when the server, not this client, is the problem.
- 2Retry-After takes whole seconds. A wait of 7.7 s becomes 8. Rounding down would invite a second refusal.
- 3Jitter spreads the retries. Without it, every refused client returns in the same second.
Tested source Go: refuse with 429
if !dec.Allowed {
secs := (dec.RetryAfter + time.Second - 1) / time.Second
w.Header().Set("Retry-After", strconv.FormatInt(int64(secs), 10))
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}- A refused request still costs one limiter call. A local tier makes that call free.
- Return the remaining count on success, so a well-behaved client can slow down early.
I refuse with 429 and Retry-After in seconds. The client waits that long plus jitter, so refused clients do not return together.
| algorithm | state per client | status |
|---|---|---|
| Fixed window | 1 counter | 2× at a boundary |
| Sliding window log | 1 entry per request | Small limits |
| Sliding window counter | 2 counters | Close to exact |
| Token bucket | tokens, last time | Approved |
| Leaky bucket (queue) | time it empties | Smooth output |
- Fixed window: one INCR, the cheapest. It forgets everything at each boundary.
- Sliding log: exact, but memory grows with the limit.
- Sliding counter: weights the last window by its overlap. The lab replay let 24 through against 20.
- Token bucket: a burst up to the bucket size, then the average rate.
- Leaky bucket: a constant output rate. Requests wait for a slot.
My default is a token bucket: it allows short bursts and holds the average. I use a sliding window when the limit must be exact.
Tested in the lab: 20 requests at 9.9 s and 20 at 10.0 s. The fixed window admitted 40. The sliding log and the sliding counter admitted 20.
A fixed window resets at its boundary, so a client can send the limit twice within 100 ms. A sliding window counts the last full window instead.
Limit: 20 requests per 10 s, an average of 2 a second. One client sends 106 requests in 40 s. Dashed lines mark the 10 s windows. Click a bar to see its first request.
- admitted
- refused, 429
- reaches the service
t = 10 s, first request in this bar > EVALSHA 952b713fee8d… rl:{burst}:fw:10000 10000 10000 20 < {1, 0, …, 0} allowed, retry after ms, remaining, delay ms < HTTP 200 OK
| algorithm | admitted | busiest 1 s | busiest 10 s (limit 20) | longest wait |
|---|---|---|---|---|
| Fixed window | 80 of 106 | 20 | 39 | none |
| Sliding log | 58 of 106 | 11 | 20 | none |
| Sliding counter | 62 of 106 | 11 | 24 | none |
| Token bucket | 84 of 106 | 20 | 35 | none |
| Leaky bucket | 84 of 106 | 2 | 20 | 9.5 s |
A token bucket promises the average rate plus one burst, not 20 in every 10 s. In any 10 s it can admit up to 20 + 10 × 2 = 40.
The fixed window lets a double burst through at the boundary. The sliding log holds exactly 20 in any 10 s. The leaky bucket sends 2 a second and makes the rest wait.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Redis | INCR and PEXPIRE | An atomic counter per window that deletes itself. A fixed window needs nothing more. | Page views, unique IDs |
| Redis | Server-side scripts (EVAL, EVALSHA) | Read the state, decide, write, as one step. No other command runs in between. | Inventory holds, idempotency |
| Redis | Sorted sets: ZADD, ZREMRANGEBYSCORE, ZCARD | A log of request times. Drop the old ones by score, count the rest. | Leaderboards, delay queues |
| Redis | Hashes: HMGET, HSET | Token bucket state: tokens and the time of the last refill, in one key. | Sessions, small records |
| Redis | Cluster hash tags: rl:{user:42} | Every key of one client lives in one slot, so one script can read two windows. | Any multi-key operation in a cluster |
| Redis | TIME | One clock for every gateway. Gateway clocks drift apart. | Leases, expiry checks |
| Redis | Asynchronous replication | Limit A failover can lose recent counts. A client may get a little extra for one window. | |
| Service | In-memory token bucket per client | The local tier: about 3.5 million decisions a second in the lab, with no network call. | Concurrency limits |
| Service | HTTP 429 and Retry-After | A standard refusal that tells the client how long to wait. | 503 during maintenance |
| API gateway | Auth, routing, limits in one stateless tier | Services stay simple. One place to change a limit. | API keys, versioning, canaries |
Redis gives me atomic counters with expiry, scripts that read and write in one step, and sorted sets for a sliding log. The gateway gives me one place to enforce it.
fixed_window(client, now): // ONE atomic script1
key = client + start of this window
count = INCR key; IF count = 1: expire key after window2
IF count <= limit: RETURN allow
RETURN refuse, retry after = window end - now3
sliding_log(client, now):
remove times <= now - window4 from the sorted set
IF size < limit: add now; RETURN allow
RETURN refuse, retry after = oldest + window - now
sliding_counter(client, now):
estimate = previous × (window - elapsed) / window5 + current
IF estimate + 1 <= limit: INCR current; RETURN allow
RETURN refuse- 1Two gateways can no longer both read 19 and both write 20. In the lab, 200 callers at once got exactly 20 admits.
- 2Expiry only cleans up. The decision reads the time passed in, so a slow expiry never admits extra requests.
- 3Exact: one ms earlier is still refused, and at this time the request passes. Tested for every algorithm.
- 4The sorted set holds one entry per admitted request. Memory grows with the limit.
- 5Assumes the last window spread its requests evenly. A burst at its end breaks that assumption, so the count can run a little over.
Tested source Redis script: fixed window · Redis script: sliding log · Redis script: sliding counter
-- Fixed window: count requests in the current window; refuse once the count passes the limit.
-- KEYS[1] the counter for this window: rl:{user:42}:fw:<window start, ms>
-- ARGV[1] now, ms ARGV[2] window, ms ARGV[3] limit
-- Returns {allowed, retry after ms, remaining, delay ms}.
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('PEXPIRE', KEYS[1], window)
end
if count <= limit then
return {1, 0, limit - count, 0}
end
local window_end = now - now % window + window
return {0, window_end - now, 0, 0}-- Sliding window log: keep the time of every admitted request; admit while fewer than the limit
-- fall inside the last window.
-- KEYS[1] sorted set, member = request id, score = time: rl:{user:42}:log
-- ARGV[1] now, ms ARGV[2] window, ms ARGV[3] limit ARGV[4] request id
-- Returns {allowed, retry after ms, remaining, delay ms}.
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, ARGV[4])
redis.call('PEXPIRE', KEYS[1], window)
return {1, 0, limit - count - 1, 0}
end
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return {0, tonumber(oldest[2]) + window - now, 0, 0}-- Sliding window counter: two fixed-window counters. The estimate weights the previous window by
-- the part of it that still overlaps the sliding window:
-- estimate = previous × (window − elapsed) / window + current
-- Multiply through by window to stay in whole numbers.
-- KEYS[1] current window counter KEYS[2] previous window counter
-- ARGV[1] now, ms ARGV[2] window, ms ARGV[3] limit
-- Returns {allowed, retry after ms, remaining, delay ms}.
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local elapsed = now % window
local curr = tonumber(redis.call('GET', KEYS[1]) or '0')
local prev = tonumber(redis.call('GET', KEYS[2]) or '0')
local used = prev * (window - elapsed) + curr * window
if used + window <= limit * window then
redis.call('INCR', KEYS[1])
redis.call('PEXPIRE', KEYS[1], 2 * window)
return {1, 0, math.floor((limit * window - used - window) / window), 0}
end
-- Refused. Find the first moment the estimate leaves room, if no other request arrives.
-- In this window: previous × (window − e) + (current + 1) × window ≤ limit × window.
if prev > 0 and curr + 1 <= limit then
local e = window - math.floor((limit - curr - 1) * window / prev)
if e < window then
return {0, e - elapsed, 0, 0}
end
end
-- In the next window the current count becomes the previous one.
local e2 = 0
if curr > 0 then
e2 = math.max(0, window - math.floor((limit - 1) * window / curr))
end
return {0, window - elapsed + e2, 0, 0}Each limiter is one Redis script. It reads the count, decides and writes in one atomic step, so 200 gateways at once admit exactly the limit.
token_bucket(client, now): // ONE atomic script
tokens = min(burst2, tokens + (now - last) × rate1)
IF tokens >= 1: tokens -= 1; RETURN allow
RETURN refuse, retry after = (1 - tokens) / rate3
leaky_bucket(client, now):
slot = max(empty_at, now) // the next free slot
IF slot - now > (capacity - 1) × interval: RETURN refuse
empty_at = slot + interval
RETURN allow, wait until slot4- 1Refill on read. No timer runs per client, so an idle client costs only one small key.
- 2The bucket size caps the burst. An idle client can send 20 at once, then 2 a second.
- 3Time until one whole token. The script counts thousandths of a token, so it stays in whole numbers.
- 4The gateway holds the request until its slot. Output is one request every 500 ms, whatever the input.
Tested source Redis script: token bucket · Redis script: leaky bucket
-- Token bucket: a bucket holds up to `burst` tokens and refills at `rate` tokens a second. Each
-- request takes one token. Tokens are counted in thousandths, so the script stays in whole numbers.
-- KEYS[1] hash with fields tokens and ts: rl:{user:42}:tb
-- ARGV[1] now, ms ARGV[2] rate, tokens a second ARGV[3] burst
-- Returns {allowed, retry after ms, remaining, delay ms}.
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) -- tokens a second = thousandths of a token per ms
local cap = tonumber(ARGV[3]) * 1000
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or cap
local ts = tonumber(state[2]) or now
if now > ts then
tokens = math.min(cap, tokens + (now - ts) * rate)
ts = now
end
local allowed, retry = 0, 0
if tokens >= 1000 then
tokens = tokens - 1000
allowed = 1
else
retry = math.floor((1000 - tokens + rate - 1) / rate)
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', ts)
redis.call('PEXPIRE', KEYS[1], math.floor(cap / rate) + 1000)
return {allowed, retry, math.floor(tokens / 1000), 0}-- Leaky bucket, as a queue: requests leave at a constant rate, one every `interval` ms. A request
-- waits for its slot. When `capacity` requests already wait, the bucket is full and refuses.
-- KEYS[1] the time the bucket is empty again: rl:{user:42}:lb
-- ARGV[1] now, ms ARGV[2] interval, ms ARGV[3] capacity
-- Returns {allowed, retry after ms, remaining, delay ms}.
local now = tonumber(ARGV[1])
local interval = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local empty_at = tonumber(redis.call('GET', KEYS[1]) or '0')
local slot = math.max(empty_at, now)
local wait = slot - now
local max_wait = (capacity - 1) * interval
if wait > max_wait then
return {0, wait - max_wait, 0, 0}
end
redis.call('SET', KEYS[1], slot + interval, 'PX', wait + interval + 1000)
return {1, 0, math.floor((max_wait - wait) / interval), wait}- Every script takes the time as an argument. The lab replays traffic on a clock it controls.
- In production, read the Redis clock, so every gateway uses one clock.
A token bucket refills at the average rate and holds a burst. A leaky bucket gives each request a time slot and refuses when the queue is full.
allow(client, now):
IF the local bucket refuses: RETURN refuse // no network call1
RETURN redis_script(client, now) // the global count- 1Refused in memory. Redis never sees this request.
Tested source Go: two tiers
// Allow implements the two tiers.
func (t *Tiered) Allow(ctx context.Context, client string, now time.Time) (Decision, error) {
if d := t.Local.Allow(client, now); !d.Allowed {
return d, nil
}
return t.Global.Allow(ctx, client, now)
}- Set the local limit above a fair share. It stops floods; the global count stays exact.
- Or take tokens from Redis in batches of 10 and spend them locally. Fewer calls, slightly less exact.
Each gateway checks an in-memory bucket first. Only the requests it admits pay for a Redis call, so a flood stops in memory.
| mode | clients see | status |
|---|---|---|
| Fail open | 200; no global limit | Public reads |
| Fail open, local tier on | 200 up to the local cap, then 429 | Approved |
| Fail closed | 503 with Retry-After | Login, paid upstream |
| Wait for Redis, no timeout | Every request hangs | Not approved |
decision = limiter(client, now) // with a short timeout1
IF the limiter fails:
IF this route fails open2: log it; serve the request
ELSE: RETURN 5033, Retry-After: 1- 1Keep it well below the latency budget of the route. A slow limiter must not slow every request.
- 2An outage of the limiter does not become an outage of the API.
- 3503, not 429: the client did nothing wrong.
Tested source Go: fail open or closed
if err != nil {
if mode == FailOpen {
log.Warn("rate limiter unavailable; failing open", "client", c, "err", err)
next.ServeHTTP(w, r)
return
}
log.Error("rate limiter unavailable; failing closed", "client", c, "err", err)
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limiter unavailable", http.StatusServiceUnavailable)
return
}Tested: with Redis unreachable, fail open answered 200 and fail closed 503. With the local tier on, fail open still refused 7 of 10 from one client.
I decide the failure mode per route in advance: open for public reads, with the local cap still on; closed for login and paid upstreams.
| event | result | why it stays safe | saved by |
|---|---|---|---|
| Redis goes down | No global count. | The chosen fail mode applies. Fail open still keeps the local cap. | Local tier |
| Redis gets slow | Every request waits on the limiter. | A short timeout, well below the route's latency budget, then the fail mode. | Timeout |
| Redis fails over | Recent counts are lost. | A client gets up to one extra window. Acceptable for a rate limit; not for billing. | Replica |
| 200 gateways ask at once | A race on the count. | The script runs as one step. Exactly the limit passes. | Script |
| Gateway clocks drift | Refill time could go negative. | The bucket never moves its last time backwards. Better: use the Redis clock. | TIME |
| Refused clients retry at once | A retry storm. | Retry-After plus jitter. The local tier refuses the storm in memory. | Retry-After |
| Gateways scale out with local limits only | Each copy allows the full limit. | The global count in Redis does not change with the number of copies. | Redis |
| A burst at the window boundary | Twice the limit within 100 ms. | Use a sliding window or a token bucket for that route. | Sliding window |
| One huge tenant on one key | One cluster slot gets all its calls. | The local tier absorbs most calls. Split the key per route. | Local tier |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | A limit in memory on each instance: the limit divided by the number of instances. | About 3.5 million decisions a second, with no network call. | Instances scale in and out, or the balancer spreads clients unevenly. Clients get the wrong limit. |
| 2 | One Redis and one script per request, as on this sheet. | About 25,000 decisions a second on one Redis in the lab. | Floods of refused requests use Redis capacity, or the round trip matters. |
| 3 | A local tier in front of Redis. | A flood stops in memory. Redis sees only what the local tier admits. | Real traffic alone nears what one Redis can do. |
| 4 | Redis Cluster, with each client's keys under one hash tag. | Capacity grows with shards. Each check stays on one shard. | Users in several regions, and a cross-region round trip per request is too slow. |
| 5 | Per-region limits. Each region enforces a share and syncs counts in the background. | Every check stays in the region. The global limit becomes approximate. | Top of the ladder. |
Demand example: 5 million requests an hour ÷ 3,600 s = 1,389 a second. Lab numbers come from one loaded laptop, so read them as orders of magnitude. A Redis server on its own host does more.
I start with one Redis and one script per request. I add a local tier when floods cost Redis calls, and a cluster when the real traffic outgrows one node.
0 of 9 known
A fixed window allows 100 requests a minute. What is the most a client can send in 1 second?
Why one Lua script, and not GET from the gateway, then INCR?
Why not MULTI and EXEC instead of a script?
Token bucket or leaky bucket?
Redis is down. Fail open or fail closed?
1 million clients, 100 requests a minute each. Memory for a sliding log, and for a sliding counter?
A fixed window of 10 s refuses a request at 12.3 s. What is Retry-After?
10 gateway copies each keep an in-memory limit of 100 a second, with no Redis. What can one client get?
What is the difference between a rate limit and a quota?
- Redis script
- About 25,000 limit decisions a second; 22,000 to 32,000 by algorithm.
- plain INCR
- About 55,000 a second. A script costs about twice an INCR.
- in memory
- About 3.5 million decisions a second.
- boundary
- A fixed window admits up to 2× the limit across a boundary. Lab: 39 in 10 s against 20.
- counter error
- Sliding counter, same burst: 24 in 10 s against 20.
- memory
- Log: 1 entry per admitted request. Counter: 2 integers. Bucket: 2 fields.
- Retry-After
- Whole seconds, rounded up: 7.7 s becomes 8.
Redis 8 and Go 1.26 on an 8-core laptop shared with other jobs, 32 callers, median of 5 runs. A hot client key and 10,000 keys measured about the same on one node.