Rate Limiter Architecture: Redis, Token Bucket & Atomic Quotas
A distributed rate limiter works because one authority — typically the Redis shard that owns a key — executes the whole decision as a single atomic step: read the bucket, refill it from the store's own clock, compare the cost, debit it, set an expiry and return allow, remaining and retry time. Gateways share no counters; they ask the owner. A Redis token bucket implemented as a Lua script gives atomic quota per key without a race between two gateways, and the surrounding design — key model, hash tags for multi-rule scripts, idempotent retries, bounded fallbacks, leased chunks for hot keys and clamped regional leases — exists to keep that guarantee honest when the store is slow, failed or far away. This page walks through the distributed rate limiting algorithms, the exact scripts, the HTTP behavior and the invariants, using a fictional Northstar API with hypothetical numbers throughout.
API Contract and Deployment Boundary
The limiter's public surface is a single question — may this request proceed? — and the answer has to carry enough structure for the gateway to respond correctly, for operators to audit the decision, and for a retry to be safe. Everything in the rest of this page is an implementation of that one call.
A word on the example before the details: Northstar API, tenants Atlas and Birch, API key K1, user U-17, gateways A and B, Redis primary R with replica R2, and regions EU and US are all fictional teaching inputs. Every capacity, rate and latency figure below is a hypothetical assumption chosen so the arithmetic is easy to follow; none of them is a measurement or an industry statistic.
Where the decision runs
There are three common placements. An in-process library inside each gateway holds the policy snapshot and talks straight to the quota store, which costs one network hop. A sidecar moves that logic into a neighbouring process so the gateway stays language-agnostic. A central limiter service exposes the decision over a network API such as gRPC or HTTP, which adds a hop and a service to operate but keeps scripts, policies and telemetry in one deployable. Envoy's global rate limiting is an example of the third shape: the proxy calls a gRPC rate-limit service for each request on routes that ask for it, and the project's reference service uses Redis behind that interface. The same page also describes pairing a local token-bucket limiter in front of the global one so that a large burst is absorbed locally before it reaches the shared service — a two-stage layout this design reuses.
The admission request
The gateway builds the request after authentication, never from raw client input. The requestId is what makes a retry idempotent — repeating the call with the same id has the same effect as making it once — which matters when a timeout leaves the outcome unknown. Time is deliberately absent: the store supplies the clock (see the token-bucket chapter), so a gateway with a skewed clock cannot influence refill.
// admit(request) — fields the gateway supplies
{
"requestId": "r-9f3a61c2e07b4d18", // minted at ingress, stable across retries (idempotency scope)
"tenantId": "atlas", // from verified credential, charset [a-z0-9-]{1,32}
"principal": { "type": "user", "id": "U-17", "apiKeyId": "K1" },
"routeGroup": "read", // GET /catalog; export and search map to their own groups
"method": "GET",
"costHint": 1, // in cost units; policy may override by route group
"policyEpoch": 42 // snapshot version the gateway evaluated against
}The decision
// decision — what the caller gets back
{
"allowed": false,
"decisionId": "r-9f3a61c2e07b4d18",
"policyId": "read-user-v3",
"policyVersion": 42,
"deniedScope": "user", // which rule said no: user | tenant | route | global | concurrency
"reason": "RATE_EXCEEDED", // RATE_EXCEEDED | CONCURRENCY_EXCEEDED | LIMITER_UNAVAILABLE | POLICY_ERROR
"limit": 20, // internal; exposure to the client is a separate decision
"remaining": 0,
"retryAfterMs": 500, // -1 means the request can never succeed under this policy
"fallback": false // true if a local degraded decision was made
}The diagram above shows the boundary as the contract sees it. The fields that matter most for correctness are policyVersion and deniedScope: the first makes a decision attributable to one immutable policy snapshot, the second tells the client and the operator which rule limited the request, which matters once several rules apply to the same call. fallback exists so that telemetry can separate decisions made with the shared store from decisions made without it.
| Decision outcome | HTTP status | Retry-After | Notes |
|---|---|---|---|
| Allowed | passes through to the backend | none | Admission only; the backend can still answer 4xx or 5xx (invariant I11) |
| Rate or concurrency exceeded | 429 | integer seconds, rounded up from retryAfterMs | A hint, not a reservation (I5); omit when retryAfterMs is -1 and alert on the policy bug |
| Limiter unavailable, policy fails closed | 503 | a conservative policy-chosen value | Dependency failure, not a quota violation; never labeled 429 |
| Limiter unavailable, bounded local fallback exhausted | 429 | from the local bucket | Explicit degraded mode; fallback is true and the metric says so |
| Credentials missing or invalid | 401 | none | Decided before the principal quota is consulted |
| Authenticated but not permitted | 403 | none | Decided before the principal quota is consulted |
The Key Model: Namespace, Version, Tenant Tag, Principal, TTL
A rate-limit key is both an identity and a routing instruction. Its text decides which counters are separate, which keys can be touched by one atomic script, which shard owns them, and when the state disappears.
rl:v3:{t:atlas}:u:7c1e0b94a2f35d68:read
│ │ │ │ │ └─ route group
│ │ │ │ └─ principal id, keyed hash (16 hex chars shown)
│ │ │ └─ principal type: u | k | ip | tenant
│ │ └─ hash tag: the only part Redis Cluster hashes, so every key of tenant Atlas shares one slot
│ └─ state-shape version (bump only when the algorithm or units change)
└─ namespaceThe annotated key and the lifetime diagram above show the two jobs a key does: it names the state and it places it. Redis Cluster computes a key's hash slot as CRC16 of the key modulo 16,384, and when the key contains a non-empty {...} section only that section is hashed as Redis Cluster defines it. That is why the tenant sits inside braces: rl:v3:{t:atlas}:u:…:read and rl:v3:{t:atlas}:tenant:atlas:all land in the same slot and can be read and written by one script. Two consequences follow immediately. First, the tag material must be sanitized: tenant ids come from the control plane and are restricted to a safe charset, because a tenant id containing } or : would let someone shape keys. Second, a tenant tag makes the tenant the unit of placement, so a very large tenant is a hot slot — a trade-off covered in the partitioning chapter.
Principal ids are keyed hashes, not raw values
The principal part is an HMAC of the identifier under a secret pepper, truncated for compactness. A plain hash of a small id space such as IPv4 addresses or sequential user ids can be reversed by enumeration, so a key dump from a debugging session would expose them; an HMAC cannot be enumerated without the pepper. Truncation to 64 bits keeps keys short while collisions stay negligible for tens of millions of principals — and because the tenant tag is outside the hashed part, a collision could only ever merge two principals inside the same tenant, never leak across tenants.
Anonymous identity: how wide is one address?
For anonymous traffic the principal is a network address, and the address family changes what one key means. An IPv4 address is a rough proxy for a household or office, though carrier-grade NAT can put thousands of people behind one. IPv6 gives one customer far more addresses: a /64 or larger delegation to a home connection is common, but delegations vary by network, so no prefix length is correct everywhere. Keying on the full address lets one host rotate through addresses it controls, each with a fresh bucket, and fills the keyspace; keying on a wide prefix lumps unrelated users together and refuses the innocent ones. Treat the prefix length as policy: choose it from the allocations you observe, make it configurable per rule (the HMAC input is then the truncated prefix, not the address), and where abuse is the concern add a second, broader prefix key as a ceiling over the narrow one. Anonymous limits are coarse by nature, which is why P-ANON is a fixed window; the u and k identities do not have this problem.
What the version segment means
v3 names the state shape: which fields exist and in which units (here, millitokens and epoch milliseconds). It is not bumped when a limit changes. Limits travel as script arguments on every call, and the stored token count is clamped to the current capacity, so a tightened policy takes effect on the next request rather than waiting for old buckets to expire. Bumping the version, by contrast, abandons all live state and every principal restarts with a full bucket — which is the right behavior for a format change and the wrong one for an emergency tightening. The policy snapshot number travels in the decision record instead.
TTL is part of the algorithm
Every write sets an expiry in the same script, so a key is never left without one. For a token bucket the expiry is the time to refill from empty plus a small slack: once that much idle time has passed the bucket would be full anyway, and deleting it is indistinguishable from keeping it. For P-READ (capacity 20, refill 2 per second) the full-refill time is 10 s, and with a 5 s slack the TTL is 15 s. For the tenant bucket of P-TENANT the full-refill time is 60 s and the TTL is 65 s. Expiry is what bounds memory: only principals active within roughly the last refill period hold state.
| Algorithm | Redis type | Fields or members | State size | Expiry |
|---|---|---|---|---|
| Fixed window | hash | w (window start ms), n (count) | O(1) | time left in the window |
| Sliding log | sorted set | member = unique request id, score = admit time ms | O(requests in window) | the window length, refreshed on write |
| Sliding counter | hash | w, cur, prev | O(1) | 2 × window |
| Token bucket | hash | tokens (millitokens), ts (ms) | O(1) | full-refill time + slack |
| GCRA | string | one integer: theoretical arrival time | O(1) | tat − now |
| Concurrency cap | sorted set | member = lease id, score = lease expiry ms | O(in-flight) | lease TTL |
Fixed Window: Alignment, Atomic Counting and the First-Create Race
A fixed window counts requests in consecutive, non-overlapping slices of time and resets to zero at each boundary. It is the cheapest algorithm to store and the easiest to explain, and its boundary behavior is the reason it is demoted to coarse jobs in this design.
With window length W and limit L, a request at time now belongs to window w = now − (now mod W), the largest multiple of W not after now. Windows are aligned to epoch multiples, so for P-ANON (20 requests per 10 s) every anonymous IP resets at the same wall-clock instants: :00, :10, :20 and so on. The request is admitted when the count in its window is below L.
The boundary burst
The diagram above makes the weakness visible. A client that sends 20 requests at t = 9.9 s uses up window one; at t = 10.1 s window two starts empty and admits another 20. That is 40 requests in 0.2 s, twice the limit, with both windows individually compliant. The same effect applies at every boundary, so the worst-case instantaneous rate is 2L per W-long interval straddling a boundary. For a coarse anonymous rule that figure is tolerable; for protecting an expensive backend it is not.
Atomic increment with expiry
The textbook form is INCR key, then EXPIRE key W when the reply is 1. Those are two commands, and the gap between them is the first-create expiry race. If the client or the gateway dies after the increment and before the expiry, the key exists with no TTL and keeps its count forever — the principal is locked out permanently, and the memory is never reclaimed. The reversed order has its own hole: SET key 0 PX … NX followed by INCR is safe only until the key expires between the two calls, at which point INCR recreates it with no TTL. Pipelining does not help; a pipeline sends commands together but does not make them one atomic unit.
A script closes the gap, because the read, the increment and the expiry run as one uninterrupted step on the shard. The version below stores the window start inside the value, which adds a second line of defense: even if a TTL were somehow lost, a stale w no longer matches the current window and the counter silently restarts at zero.
-- KEYS[1] hash {w, n} ARGV[1] limit ARGV[2] window_ms
-- returns {allowed, remaining, retry_after_ms}
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local limit, window = tonumber(ARGV[1]), tonumber(ARGV[2])
local w = now - (now % window)
local state = redis.call('HMGET', KEYS[1], 'w', 'n')
local n = 0
if tonumber(state[1]) == w then n = tonumber(state[2]) end
local ms_left = w + window - now
if n >= limit then
return {0, 0, ms_left}
end
n = n + 1
redis.call('HSET', KEYS[1], 'w', w, 'n', n)
redis.call('PEXPIRE', KEYS[1], ms_left)
return {1, limit - n, 0}Mentally running the script on the boundary example: at now = 9,900 ms the window start is 0 and the 20th request leaves remaining 0; the 21st returns {0, 0, 100} — denied, with 100 ms until the window ends. At now = 10,100 ms the stored w is 0 but the current window start is 10,000, so the count restarts and 20 more requests are admitted. The boundary burst is therefore the algorithm's behavior, not a bug in the script.
Sliding-Window Log: Exact Counting at a Memory Price
The sliding log keeps one record per admitted request and, at decision time, counts how many fall inside the trailing window. It has no boundary artifact, and it pays for that with state that grows with traffic instead of staying constant.
In Redis the natural structure is a sorted set. The score is the admission time in milliseconds and the member is a unique request identifier. A decision is three steps: trim members whose score is at or before now − W, count what remains, and add the new member if the count is below the limit. Trimming first defines the window as the half-open interval (now − W, now], so a request admitted exactly W ago has just expired. The diagram above lays these three steps out on a timeline.
-- KEYS[1] sorted set (member = unique request id, score = admit time ms)
-- ARGV[1] limit ARGV[2] window_ms ARGV[3] member
-- returns {allowed, remaining, retry_after_ms}
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local limit, window = tonumber(ARGV[1]), tonumber(ARGV[2])
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
local count = redis.call('ZCARD', KEYS[1])
if redis.call('ZSCORE', KEYS[1], ARGV[3]) then
return {1, limit - count, 0}
end
if count >= limit then
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return {0, 0, tonumber(oldest[2]) + window - now}
end
redis.call('ZADD', KEYS[1], now, ARGV[3])
redis.call('PEXPIRE', KEYS[1], window)
return {1, limit - count - 1, 0}Uniqueness and ties
Two requests can be admitted in the same millisecond. If the member were the timestamp, the second ZADD would overwrite the first and the log would undercount, silently admitting extra traffic. The member must therefore be a unique identifier, and the request's decision id is a convenient choice: replaying the same id finds the existing member (the ZSCORE check above) and returns allowed without adding a second entry, which gives the log idempotent retries for free within its window.
Memory is the real constraint
State is O(number of requests inside the window), per key. A tenant limited to 1,000 requests per minute can hold up to 1,000 entries; 20,000 active users limited to 20 requests per window can hold 20,000 × 20 = 400,000 entries. That is manageable at those numbers and unmanageable for a 100,000-per-minute partner key or a window of hours, and a hot key also means a large sorted set trimmed and inspected on every call. Denied requests do not add entries, which at least keeps a flooding client from growing its own log past the limit.
Retry timing is exact for a unit-cost request: the oldest member leaves the window at oldestScore + W, so the hint is that value minus now — 10,000 ms in the test case where 20 entries were all admitted at t = 1,000 ms and the 21st arrives at the same instant. Weighted costs would need several entries or a cumulative scan, which is one reason the log suits low-volume, high-value limits such as audit-sensitive actions rather than the main per-user path.
Sliding-Window Approximate Counter: Cheap, Smooth, Not Exact
The approximate counter keeps just two numbers per key, the count in the current window and the count in the previous one, and estimates the trailing window by weighting the previous count by how much of it still overlaps.
The estimate
est = prev × (1 − elapsed / W) + curr, where elapsed is the time since the current window started. The weight on prev falls linearly from 1 to 0 across the window.
In the diagram, take the canonical case: W = 10 s, limit 20, previous window 18, current window 5, and elapsed 2.5 s. The previous window still overlaps 75 % of the trailing interval, so est = 18 × 0.75 + 5 = 18.5. Admitting one more would make the estimate 19.5, which is at most 20, so the request is allowed. The script below does the comparison in integers by multiplying through by W, which avoids floating-point rounding: the condition is prev × (W − elapsed) + cur × W + W ≤ limit × W, and here that is 135,000 + 50,000 + 10,000 = 195,000 ≤ 200,000.
-- KEYS[1] hash {w, cur, prev} ARGV[1] limit ARGV[2] window_ms
-- returns {allowed, estimate_x_window} (estimate is approximate by construction)
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local limit, window = tonumber(ARGV[1]), tonumber(ARGV[2])
local w = now - (now % window)
local s = redis.call('HMGET', KEYS[1], 'w', 'cur', 'prev')
local cw, cur, prev = tonumber(s[1]), tonumber(s[2]) or 0, tonumber(s[3]) or 0
if cw == nil or w - cw >= 2 * window then
cur, prev = 0, 0
elseif w - cw == window then
prev, cur = cur, 0
end
local elapsed = now - w
local scaled = prev * (window - elapsed) + cur * window
if scaled + window > limit * window then
return {0, scaled}
end
redis.call('HSET', KEYS[1], 'w', w, 'cur', cur + 1, 'prev', prev)
redis.call('PEXPIRE', KEYS[1], 2 * window)
return {1, scaled + window}What the approximation assumes
The formula assumes the previous window's requests were spread uniformly. When they are not, the estimate is wrong in both directions. Suppose all 18 requests of the previous window arrived in its last 2.5 s: at elapsed = 2.5 s the true trailing-window count is 18 plus the current count, but the estimate has discounted the 18 to 13.5. Requests keep being admitted until the estimate would exceed the limit, so the current window can grow to 6 and the true trailing count reaches 18 + 6 = 24 against a limit of 20, which is 20 % over. If instead the 18 had arrived at the very start of the previous window, the estimate overstates and the limiter denies traffic the true window would allow. The error is bounded by the size of the previous window's count, which is why the method is described as approximate and never as exactly enforcing the limit.
Token Bucket: Refill Math, Retry Time and Input, Overflow and Clock Behavior
A token bucket holds up to capacity tokens, receives refill tokens per period, and spends cost tokens per request. It is the primary algorithm of this design because it models what operators usually mean — a sustained rate plus a bounded burst — in constant space and with a precise retry time.
Refill, in one line
tokens = min(capacity, old + refillRate × max(0, now − last)). Then the request is allowed if tokens ≥ cost, and in that case tokens decreases by cost and last moves to now.
Units: millitokens and milliseconds
Fractional tokens are real: 300 ms at 2 tokens/s is 0.6 tokens. Floating-point state would work in a single process but is awkward to compare, serialize and reason about across replicas. The design stores tokens as an integer count of millitokens and time as integer epoch milliseconds, so 1 token = 1,000 millitokens, capacity 20 is 20,000, and 2 tokens/s is expressed as 2,000 millitokens per 1,000 ms. Writing the rate as a pair (refill amount, period in ms) rather than a single per-second value keeps awkward rates exact: P-TENANT is 1,000,000 millitokens per 60,000 ms, which is 16.67 tokens/s that has no clean decimal form per second.
The P-READ timeline
| Time | Event | Tokens after | Result |
|---|---|---|---|
| t = 0, idle bucket | bucket starts full | 20 | — |
| t = 0 | 20 requests arrive | 0 | all 20 allowed |
| t = 0 | 21st request | 0 | denied; deficit 1 token → retry = 1 / 2 = 0.5 s = 500 ms; HTTP Retry-After: 1 (rounded up to whole seconds) |
| t = 0.3 s, no further consumption | refill 600 millitokens | 0.6 | a request here is still denied; 400 millitokens short → 200 ms |
| t = 5 s, no consumption | refill 2 × 5 | 10 | 10 requests allowed |
| t = 10 s idle | refill reaches the cap | 20 | capped at capacity; more idle time adds nothing |
The diagram above plots the same timeline. The final row matters for memory as well as arithmetic: after 10 s without requests the bucket is exactly as if it had just been created, which is why the key can expire at that point.
Burst is part of the contract
Over any span of T seconds a bucket can admit up to capacity + refillRate × T: for P-READ, 20 + 2 × 10 = 40 requests in 10 s, twice what a strict "20 per 10 seconds" window would allow. This is the intended burst behavior, and it is also the reason a token bucket is not a sliding window with the same numbers (invariant I4). If a downstream dependency really cannot tolerate that burst, the capacity should be lowered, or a second, stricter rule added.
Weighted cost and retry time
An export carries cost 10 against a bucket with the same 20-and-2 shape (for illustration). If 3 tokens remain, the deficit is 7, and 7 / 2 = 3.5 s, so the hint is 3,500 ms and the HTTP header is Retry-After: 4, rounded up to an integer number of seconds. Rounding up is deliberate: a client that returns at 3 s would be denied again. A cost larger than capacity can never succeed, no matter how long the client waits; that case is reported separately (retry −1) instead of producing an ever-growing hint.
Validation, overflow and clock behavior
| Concern | Rule in the script | Why |
|---|---|---|
| Non-numeric, NaN, fractional or negative inputs | all of capacity, refill, period and cost must be integers in [1, 2^40], slack in [0, 2^40], and capacity × period at most 2^52 | Lua's tonumber can accept strings such as "nan" and "inf"; comparing a value with itself catches NaN, and the capacity × period cap keeps every product the script forms (delta × refill, deficit × period) below 2^53, where Lua's doubles stop being exact integers |
| Cost greater than capacity | return retry −1 without touching state | Waiting cannot help; this is a policy defect to alert on |
| Refill rate 0 | rejected as a policy error | A bucket that never refills would also expire by TTL and silently reset to full. A zero rate means a hard block (decide before the store) or a fixed budget with an explicit period — a different quota type |
| Clock moved backwards | delta = max(0, now − ts); stored ts = max(ts, now) | No refill until the clock catches up; stricter than the true state, never more generous |
| Clock jumped far forward | delta is clamped to the time needed to fill the bucket | Worst case one free refill to capacity, no overflow |
| Time source | the store's own TIME, in the same script | Gateways disagree on the time by milliseconds to seconds; a single authority gives every request the same clock |
| Stored tokens above a lowered capacity | tokens = min(capacity, …) on every call | An emergency tightening applies immediately to existing buckets |
| Rounding | floor on refill, ceiling on retry | Loses less than one millitoken per allowed request, in the strict direction; retry hints are never optimistic |
Leaky Bucket and GCRA: Shaping Versus Policing
"Leaky bucket" names two different things, and mixing them up is the commonest way to design the wrong component. One smooths output by queueing; the other only decides admit or reject.
A leaky bucket as a queue accepts requests into a finite buffer and releases them at a constant rate. Output is perfectly smooth, and the cost is latency, queue memory and a worker that drains the buffer: it is traffic *shaping*. A leaky bucket as a meter keeps only a level that rises with each request and drains at a constant rate; a request is rejected when adding it would exceed the bucket depth. That is *policing*, and it is mathematically the mirror image of a token bucket: level = capacity − tokens. This module's limiter polices; it never holds requests. Delaying or queueing is a separate design with its own capacity and timeout questions. The diagram below lines the three policing formulations up against the shaping queue.
GCRA, the same meter with one number
The Generic Cell Rate Algorithm stores a single timestamp, the theoretical arrival time (TAT): the moment at which the bucket would be completely drained if no more requests came. With emission interval T = period / refill (500 ms for 2 tokens/s) and burst depth B = capacity × T (10,000 ms for 20 tokens), a request of cost c is admitted when max(TAT, now) + c × T − now ≤ B, and then TAT becomes max(TAT, now) + c × T.
function gcra(tat, now, cost, T, B): # all in integer ms
start = max(tat, now)
newTat = start + cost * T
if newTat - now > B:
return { allowed: false, retryAfterMs: newTat - now - B, tat: tat } # nothing stored
return { allowed: true, retryAfterMs: 0, tat: newTat } # store newTat, expiry = newTat - nowRun it on P-READ with the bucket fresh (tat = now): requests 1 to 20 give newTat − now = 500, 1,000, …, 10,000, all within B; the 21st gives 10,500 > 10,000 and is denied with a hint of 500 ms — the same answer the token bucket produced. After 5 s of idle on a bucket whose TAT stands 10,000 ms ahead, the next request has newTat − now = 10,500 − 5,000 = 5,500, which is admitted; the equivalent token count before it was (10,000 − 5,000) / 500 = 10, matching the bucket.
| Property | Token bucket (hash of two fields) | GCRA (one integer) |
|---|---|---|
| State | tokens + last-update timestamp | theoretical arrival time |
| Refill computed | on each request, from elapsed time | implicit in the max(TAT, now) step |
| Natural to report remaining tokens | yes | derivable: (B − (TAT − now)) / T |
| Fractional rates | exact with the (refill, period) pair | needs T as an integer; rounding or finer units (µs) otherwise |
| Weighted cost | cost × millitokens | cost × T |
| Observability and debugging | fields are readable and self-explanatory | opaque timestamp; harder to eyeball |
Making the Decision Atomic: The Reference Pseudocode and the Redis Script
A rate-limit decision is a read, a computation and a write. If another request can run between the read and the write, two gateways can both spend the same last token. The cure is to put the whole sequence inside one operation that the store executes without interleaving.
The conceptual reference
function evaluateTokenBucket(key, capacity, refillPerSec, cost, now):
assert 0 < cost <= capacity
bucket = read(key) or { tokens: capacity, updatedAt: now }
delta = max(0, now - bucket.updatedAt)
available = min(capacity, bucket.tokens + delta * refillPerSec)
allowed = (available >= cost)
if allowed: available -= cost
storeAtomically(key, tokens=available, updatedAt=now, ttl=appropriateIdleTTL)
retryAfter = 0 if allowed else ceil((cost - available) / refillPerSec)
return { allowed, remaining: available, retryAfter }This is a teaching model, not an implementation. read and storeAtomically are two separate operations, so the function as written is *not* atomic across callers: gateways A and B can both read one remaining token and both store a decrement. It also leaves several things open that a real system must settle: now is passed in by the caller, so the time source is whatever each gateway's clock says; refillPerSec = 0 divides by zero in the retry computation; assert is a process crash rather than a decision a caller can act on; fractional refill is floating point; it writes the bucket even on denial, which is a choice rather than a necessity; and the retry hint comes out in seconds while the rest of the system works in milliseconds.
Why one script solves the race
Redis executes a Lua script or function as one uninterrupted unit on a single instance: while it runs, other clients' commands wait, so the script's effects either have not happened yet or have already happened. That is exactly the read-compute-write isolation the pseudocode needs. The same documentation notes that scripts replicate to replicas by their *effects* — the resulting write commands — which has been the default since Redis 5.0 and the only mode since 7.0, and that with effects replication the older restriction on non-deterministic commands is lifted, so calling TIME inside a script is legitimate. Redis's own rate-limiter guides take the same approach and read the server clock inside the script, which removes disagreement between application servers about what time it is.
The cost of this isolation is that nothing else runs on that instance while the script does. A script must therefore be short and bounded: no loops over unbounded data, a capped number of rules, and no commands whose cost grows with the key's size. Redis lets a script run until the busy-reply threshold (five seconds by default), after which other clients start receiving BUSY errors — a limiter script should be a very short operation by design, and its real execution time is something to measure on your own hardware rather than assume.
The production form
-- KEYS[1] bucket hash {tokens, ts}
-- ARGV[1] capacity (millitokens) ARGV[2] refill (millitokens per period)
-- ARGV[3] period_ms ARGV[4] cost (millitokens)
-- ARGV[5] ttl_slack_ms
-- returns {allowed, remaining_millitokens, retry_after_ms} (-1 = can never succeed)
local function valid(x, min)
return x ~= nil and x == x and x >= min and x <= 2 ^ 40 and x % 1 == 0
end
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local period = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local slack = tonumber(ARGV[5])
if not (valid(capacity, 1) and valid(refill, 1) and valid(period, 1)
and valid(cost, 1) and valid(slack, 0)
and capacity * period <= 2 ^ 52) then
return redis.error_reply('ERR rate limit policy arguments invalid')
end
if cost > capacity then
return {0, 0, -1}
end
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil or ts == nil then
tokens = capacity
ts = now
end
local full_ms = math.ceil(capacity * period / refill)
local delta = math.min(math.max(0, now - ts), full_ms)
tokens = math.min(capacity, tokens + math.floor(delta * refill / period))
if tokens >= cost then
tokens = tokens - cost
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', math.max(ts, now))
redis.call('PEXPIRE', KEYS[1], full_ms + slack)
return {1, tokens, 0}
end
return {0, tokens, math.ceil((cost - tokens) * period / refill)}A walk through P-READ confirms the units. Arguments: capacity 20,000, refill 2,000, period 1,000, cost 1,000, slack 5,000. For a fresh key, tokens = 20,000 and ts = now. full_ms = ceil(20,000 × 1,000 / 2,000) = 10,000. Twenty calls in the same millisecond each find delta = 0, subtract 1,000 and return {1, remaining, 0}; the last returns 0 remaining. The 21st call finds 0 tokens, retry = ceil(1,000 × 1,000 / 2,000) = 500 and returns {0, 0, 500} without writing — so the denied request changes nothing. Five seconds later with no consumption, delta = 5,000, refill = floor(5,000 × 2,000 / 1,000) = 10,000 millitokens. A cost-10,000 request against 3,000 millitokens returns {0, 3000, 3500}; the gateway turns 3,500 ms into Retry-After: 4. After every allowed call the key expires in full_ms + slack = 15,000 ms.
Choices the script makes, stated out loud
- Denied requests do not consume. A deny returns before any write, so a flooding client cannot drain its own bucket below what honest retry timing would give it, and a denied request costs no replication traffic. The alternative — charging attempts to discourage hammering — is a legitimate policy, but it needs its own rule and its own counter.
- Only the store's clock is used.
tsis never taken from the caller, andts = max(ts, now)keeps the stored timestamp from moving backwards. - Keys and arguments are separated. The bucket key is passed in
KEYS, limits inARGV; the script computes no key names, so it stays valid under Redis Cluster. - TTL is set in the same write. No code path can store state without an expiry.
- Parameter errors surface as errors. A malformed policy yields an error reply the client classifies as a policy fault, distinct from a store outage.
Atomic is not durable
The diagram above shows the sequence. Atomicity is a property of one instance at one moment. Redis replication is asynchronous by default: the primary acknowledges the write before every replica has it. If R fails and R2 is promoted before it has applied the last few decisions, those debits are gone, and the promoted instance admits as though they never happened. Redis Cluster's design acknowledges this window plainly, and Redis replication adds that WAIT can make the loss much less likely by blocking until replicas acknowledge, yet writes can still be lost in a failover depending on configuration — it narrows the window; it does not make the system strongly consistent. The bound for a limiter is small and finite: a lost bucket state can make at most that bucket's consumed budget reappear — at most its capacity, 20 for a P-READ user bucket and 1,000 for Atlas's tenant bucket. Restoring from an RDB or AOF snapshot and key eviction under maxmemory have the same shape: an older state or a missing key means a fuller bucket. Evicting counters silently resets enforcement, so the limiter store should use noeviction, or an eviction policy only over keys that are truly disposable, and rely on TTLs for cleanup.
Hierarchical Rules, Hash Slots, Weighted Cost and Concurrency
A single request rarely faces one limit. It may need the per-user bucket, the tenant quota and a route budget all to agree, and the interesting question is whether those checks happen as one atomic step or as a sequence that can be left half-done.
All rules or none
The rule is conjunctive: every applicable limit must permit the request. Evaluated sequentially against separate stores, a denial by the second rule leaves the first rule already debited — a *partial debit*: the user bucket lost a token for a request the tenant rule refused. Undoing it needs a refund, and a refund after an ambiguous failure is how quotas get double-refunded. The better design avoids the question by evaluating all rules in one script that reads every bucket, decides, and writes only if every rule allows. A denial anywhere debits nothing.
In Redis Cluster this is only possible when all the keys live in one hash slot; multi-key operations over keys in different slots fail with a CROSSSLOT error Hash tags are the tool: {t:atlas} in every key of tenant Atlas puts the user bucket, the tenant bucket, the route budget and the decision record on one slot, as the diagram above shows. Without the tag the user and tenant keys would hash independently and, on a multi-shard cluster, the combined script would be rejected.
-- KEYS[1] decision key (idempotency record) KEYS[2..n+1] buckets, same hash slot
-- ARGV[1] ttl_slack_ms ARGV[2] decision_ttl_ms (0 = no idempotency record)
-- ARGV[3+4i..] per bucket i (0-based): capacity, refill, period_ms, cost (millitokens / ms)
-- returns {allowed, denied_by_index, retry_after_ms, remaining_1 .. remaining_n}
local function valid(x, min)
return x ~= nil and x == x and x >= min and x <= 2 ^ 40 and x % 1 == 0
end
local n = #KEYS - 1
local slack, dec_ttl = tonumber(ARGV[1]), tonumber(ARGV[2])
if n < 1 or #ARGV ~= 2 + 4 * n or not (valid(slack, 0) and valid(dec_ttl, 0)) then
return redis.error_reply('ERR rate limit policy arguments invalid')
end
if dec_ttl > 0 then
local prior = redis.call('GET', KEYS[1])
if prior then
return cjson.decode(prior)
end
end
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local rules = {}
local denied_by, retry = 0, 0
for i = 1, n do
local b = 3 + 4 * (i - 1)
local capacity, refill = tonumber(ARGV[b]), tonumber(ARGV[b + 1])
local period, cost = tonumber(ARGV[b + 2]), tonumber(ARGV[b + 3])
if not (valid(capacity, 1) and valid(refill, 1) and valid(period, 1) and valid(cost, 1)
and capacity * period <= 2 ^ 52) then
return redis.error_reply('ERR rate limit policy arguments invalid')
end
if cost > capacity then
return {0, i, -1}
end
local state = redis.call('HMGET', KEYS[i + 1], 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil or ts == nil then
tokens = capacity
ts = now
end
local full_ms = math.ceil(capacity * period / refill)
local delta = math.min(math.max(0, now - ts), full_ms)
tokens = math.min(capacity, tokens + math.floor(delta * refill / period))
rules[i] = { tokens = tokens, ts = math.max(ts, now), cost = cost, ttl = full_ms + slack }
if tokens < cost then
if denied_by == 0 then denied_by = i end
retry = math.max(retry, math.ceil((cost - tokens) * period / refill))
end
end
local result = {0, denied_by, retry}
if denied_by == 0 then
result[1] = 1
for i = 1, n do
local r = rules[i]
r.tokens = r.tokens - r.cost
redis.call('HSET', KEYS[i + 1], 'tokens', r.tokens, 'ts', r.ts)
redis.call('PEXPIRE', KEYS[i + 1], r.ttl)
end
end
for i = 1, n do
result[3 + i] = rules[i].tokens
end
if dec_ttl > 0 then
redis.call('SET', KEYS[1], cjson.encode(result), 'PX', dec_ttl)
end
return resultWalk-through for U-17 on GET /catalog: rules are the user bucket (capacity 20,000, refill 2,000 per 1,000 ms) and the tenant bucket (capacity 1,000,000, refill 1,000,000 per 60,000 ms), each with cost 1,000. When U-17 has 1,000 left and the tenant has 639,000, the call returns {1, 0, 0, 0, 638000}: allowed, no denying rule, no retry, remaining 0 and 638,000 millitokens. If the user bucket is already empty the call returns {0, 1, 500, 0, 639000}: denied by rule 1, retry 500 ms, and the tenant bucket still holds 639,000 — nothing was debited. If the tenant bucket instead holds only 3,000 millitokens and the request costs 10,000, the call returns {0, 2, 420, …} — retry = ceil(7,000 × 60,000 / 1,000,000) = 420 ms — and the user bucket stays untouched. With several denying rules the hint is the maximum, since all must be satisfiable, and denied_by names the first denying rule in evaluation order.
- Listing the same key twice in
KEYS: both entries would read the same stale state and the second write would overwrite the first. The control plane must reject duplicate keys. - Unbounded rule lists: script time grows with the number of rules. Cap them (a handful per request) and validate at publish time.
- Evaluating any rule that needs a different slot inside this script. Those rules need leases or an explicit sequence, below.
Which rule rejects first, and what is exposed
Evaluation order affects reporting, not enforcement: all rules are always checked, and the first denying rule is the one named in deniedScope. Put the most specific, least sensitive rule first (user, then route, then tenant), so the client is told about its own limit before it learns anything about a shared one. For a caller without permission to see tenant-wide capacity, the response should name only the scope the caller can act on.
What cannot be atomic across shards
The fleet-wide P-GLOBAL budget (40,000 requests/s on GET /catalog) lives in its own key and its own slot; it cannot share a script with Atlas's keys without forcing all traffic onto one shard. Two ways exist to combine it with per-tenant rules. Sequential checks with compensation: debit global, then debit the tenant script, and if the tenant script denies, release the global debit. That works only if the compensation is idempotent, which a token refund is not. Alternatively, leasing: the gateway holds a locally spendable chunk of the global budget (chapter on hot keys) and consults the global store only to renew it, so the per-request path stays single-shard. This design uses leasing for P-GLOBAL and treats the global budget as approximate by exactly the size of the parked chunks.
Weighted cost is not concurrency
The diagram above separates the two gates. P-EXPORT combines two mechanisms, deliberately. Weighted cost — 10 units per export against the rate bucket — controls how much capacity exports consume per unit time, so ten cheap reads and one export are accounted fairly. Concurrency — at most 3 in-flight exports per tenant — controls how many run at the same time, which a request-rate bucket cannot: a tenant can issue an export per second with minute-long runtimes and pile sixty jobs onto the backend while staying inside any plausible request rate. The concurrency check is a semaphore, a different state machine: acquire a lease with an expiry, release it when the export finishes, and let expiry reclaim leases whose holder crashed.
-- KEYS[1] sorted set of in-flight leases (member = lease id, score = lease expiry ms)
-- ARGV[1] lease_id ARGV[2] max_in_flight ARGV[3] lease_ttl_ms
-- returns {acquired, in_flight}
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local limit, ttl = tonumber(ARGV[2]), tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
if redis.call('ZSCORE', KEYS[1], ARGV[1]) then
return {1, redis.call('ZCARD', KEYS[1])}
end
local in_flight = redis.call('ZCARD', KEYS[1])
if in_flight >= limit then
return {0, in_flight}
end
redis.call('ZADD', KEYS[1], now + ttl, ARGV[1])
redis.call('PEXPIRE', KEYS[1], ttl)
return {1, in_flight + 1}The release is ZREM key leaseId, which is idempotent by construction — releasing twice, or releasing a lease that already expired, changes nothing. This is the property that makes compensation safe here: if the lease is taken and then the rate bucket denies, the gateway releases the lease and nothing is double-refunded. The ordering is therefore lease first, token debit second, with an idempotent release on denial. Both operations are keyed under the tenant tag and could be merged into one script if the extra round trip matters. A denial from the concurrency rule has no time-based retry hint, because it clears when an export finishes, not at a computable instant; the 429 uses a policy-chosen Retry-After instead, and long exports should renew their lease with a heartbeat shorter than its TTL.
Resilience: Timeouts, Breaker, Pool, Ambiguous Outcomes and Fallback
The limiter is on the critical path of every request, so it has to fail in a way that is chosen in advance. The hardest failure is not an error; it is a timeout, because after one the gateway does not know whether the script ran.
Budgets and bounds
Every wait has a bound (invariant I10). The decision gets a single deadline, taken from a monotonic clock, shorter than the request's own budget — for illustration a few milliseconds for the cheap read path. The connection pool is small and bounded, acquisition waits at most about a millisecond, and a request that cannot get a connection degrades immediately instead of queueing behind others; unbounded queues turn a slow store into a slow gateway. A circuit breaker per shard counts timeouts and errors, opens when they exceed a threshold, and lets a limited number of probe calls through when half-open. The usual hazard to avoid is a retry multiplier: three attempts from 100 gateways against a struggling shard triples its load exactly when it can least afford it.
Two kinds of failure
| Phase when the failure occurs | What is known | Safe action |
|---|---|---|
| Before any bytes are sent: breaker open, pool exhausted, connect refused | The script did not run | Degrade per policy; a retry is safe |
NOSCRIPT reply to EVALSHA | The script did not run (it was not in the cache) | Load the script and retry once |
| Error reply from the script (invalid arguments) | The script rejected the input before changing state | Treat as a policy fault; do not retry; alert |
| Timeout or connection reset after the command was sent | Unknown: the script may have run and debited | Retry only with an idempotency record; otherwise degrade and accept at most one extra unit |
The scripting documentation recommends calling EVALSHA and handling NOSCRIPT by loading the script, because the script cache is volatile across restarts and failovers; the gateway must therefore be ready to re-load on the new primary after a failover.
function admit(req, policy):
decisionId = req.requestId # minted at ingress; the same value on every retry
deadline = monotonicNow() + policy.budgetMs
shard = owner(req.tenantTag)
if breaker[shard].isOpen():
return degrade(req, policy, "BREAKER_OPEN")
maxAttempts = 2 if policy.idempotent else 1
attempt = 0
while attempt < maxAttempts and monotonicNow() < deadline:
attempt += 1
conn = null
phase = "BEFORE_SEND"
try:
conn = pool[shard].acquire(maxWaitMs = min(1, remaining(deadline)))
phase = "SENT"
reply = conn.evalsha(script, keysFor(req), argsFor(req, policy), timeoutMs = remaining(deadline))
breaker[shard].recordSuccess()
pool[shard].release(conn)
return toDecision(reply, req, policy)
catch error:
breaker[shard].recordFailure(error)
if conn != null: pool[shard].discard(conn) # a timed-out connection may still receive a late reply
if error is NoScript and phase == "SENT":
loadScript(shard); attempt -= 1; continue # did not execute; free retry, still bounded by the deadline
if error is ScriptError:
return degrade(req, policy, "POLICY_ERROR") # no retry
if phase == "BEFORE_SEND":
return degrade(req, policy, "STORE_UNREACHABLE")
# phase == "SENT" and a timeout or reset: outcome unknown
if policy.idempotent: continue # same decisionId: the script replays its stored result
return degrade(req, policy, "AMBIGUOUS") # no blind retry, no compensating refund
return degrade(req, policy, "DEADLINE")policy.idempotent is true for rules whose script call carries a decision record (the decision_ttl_ms argument of the multi-rule script is non-zero): exports, lease grants and other expensive or hard-to-undo spends. On a retry the script finds the record under the same decision id and returns the stored result without debiting again, so a lost reply cannot cause a double debit. The retention is bounded — long enough to cover the call's deadline and retries (tens of seconds in the example), short enough that the records stay cheap. At a hypothetical 100 exports/s with 30 s retention that is 3,000 records. Applying the same record to every cheap read would be 500,000/s × 30 s = 15 million records at peak, many times more keys than the limiter itself, which is why cheap reads do not use it. A retry after retention expires would debit again; that residual risk is bounded to one operation. The decision id must be minted by the gateway, scoped to the tenant, and generated with enough randomness that two different requests cannot collide.
For non-idempotent cheap calls, an ambiguous timeout is accepted as a possible single extra debit of one unit (1 token of 20) and the request follows the degraded policy. What must not happen is a compensating refund (INCR back): if the debit never ran, the refund creates a free token, and if a retry then also refunds, the bucket gains tokens it never lost.
Degraded decisions by route class
| Policy | Store unavailable | Reason | Bound on admitted excess |
|---|---|---|---|
| P-READ, per-user cheap reads | bounded local fallback bucket per gateway: capacity 5, refill 0.25/s | Availability matters and the backend is separately protected | A user spread over 4 gateways could receive at most 4 × (5 + 0.25 × 60) = 80 in 60 s, versus 20 + 2 × 60 = 140 under the shared bucket in the same minute |
| P-EXPORT, expensive | fail closed: 503 with a conservative Retry-After | An unbounded export burst is costlier than refusing exports for a while | 0 additional exports admitted |
| P-ANON on /login | bounded local fixed-window per gateway, small cap | Locking everyone out of login is itself an outage; downstream credential throttling remains | gateways seeing the IP × local cap per window |
| P-GLOBAL leased chunks | spend what is held (≤ 100 tokens per gateway, valid ≤ 250 ms) | The lease is already a local budget | Fleet-wide parked tokens ≤ 100 × 100 = 10,000 = 0.25 s of global capacity |
The diagram above shows the other half of resilience, which is in the client. If 10,000 clients are told Retry-After: 1, all return in the same second and recreate the spike; with jitter spread over roughly one second the same 10,000 arrive at about 10,000 per second on average instead of all in the same instant. Gateways also keep a short negative cache for a denied key — deny locally for up to the returned hint, capped at 250 ms in this example — so a flood against an exhausted key is absorbed at the gateway instead of reaching the shard. See retry with backoff, circuit breaker and load shedding for the general patterns.
HTTP Behavior: 429, 401, 403, 503, Retry-After and RateLimit Headers
The HTTP response is where a correct decision becomes either a usable signal or a new source of load. Status code, ordering relative to authentication, and the retry hint each have one correct place in the pipeline.
Order of checks
| Stage | Check | Rejection | Why here |
|---|---|---|---|
| 1. Edge, before authentication | coarse per-IP limit (P-ANON, fixed window) | 429 with Retry-After | Protects authentication itself from floods and from credential guessing; state is keyed by IP and tightly bounded |
| 2. Authentication | verify the credential; derive tenant and principal | 401 | Quotas are keyed by verified identity; failed attempts are counted against the stage-1 IP budget, not against any tenant |
| 3. Authorization | may this principal call this route? | 403 | A request that would be refused anyway should not spend the tenant's quota, and refusing earlier avoids exposing quota state to unauthorized probes |
| 4. Principal quota | the rules of the previous chapters: user, route, tenant, concurrency | 429 with Retry-After, or 503 if the limiter is unavailable and the policy fails closed | Only authenticated, authorized traffic spends per-tenant state |
| 5. Backend | handler runs | its own 2xx, 4xx or 5xx | Admission is not a delivery guarantee (I11) |
The two quotas answer different questions and are not substitutes. The IP check protects the front door from callers who have not proved who they are, including a flood of bogus credentials that would otherwise cost authentication CPU and could create unbounded per-key state. The principal check protects shared backend capacity from callers who have. A design that runs only the second lets unauthenticated traffic through to the expensive part; one that runs only the first lets an authenticated partner behind a shared NAT (network address translation: many users appearing as one public IP) or a rotating pool of IPs through unchecked.
Status semantics
429 Too Many Requests is the status defined in RFC 6585 for a user sending too many requests in a given time; the specification permits a Retry-After header to say how long to wait. 503 Service Unavailable says the service cannot handle the request now. They are not interchangeable: a quota violation by a valid caller is the caller's to fix by slowing down, a limiter outage is the operator's problem, and clients, retry libraries and SLO dashboards treat them differently. When a policy deliberately degrades to a local fallback and that local bucket is empty, the response is 429 because a defined limit rejected it; when the policy fails closed because the store is unreachable, the response is 503.
Not every 429 a client sees comes from this limiter. An upstream CDN, a partner gateway, or the backend itself can each answer 429 for their own reasons, and a request this limiter admitted can still be rejected downstream. Telemetry should tag the originating layer (decision.source) so incident reviews do not attribute every 429 to one component.
Retry-After
RFC 9110 defines Retry-After as either an HTTP-date or a non-negative integer number of seconds. This design emits integer seconds: it cannot be misread because of client or server clock skew, and it is trivial to compute. The conversion from the script's millisecond hint is max(1, ceil(retryAfterMs / 1000)). A 500 ms hint (the 21st request on a drained P-READ bucket) becomes Retry-After: 1; the 3,500 ms export hint becomes Retry-After: 4. Rounding up is deliberate, as a client that returns early is denied again.
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/problem+json
{ "type": "about:blank", "title": "Rate limit exceeded", "status": 429,
"detail": "Too many requests for this credential. Retry after the interval given.",
"decisionId": "r-9f3a61c2e07b4d18" }The value is a hint, not a reservation (I5). Between the denial and the retry, other requests from the same principal, other gateways, or a policy change can consume the capacity the hint expected to be free. The server may also add random jitter to the hint so that clients denied in the same instant do not return in the same instant, which, combined with jittered exponential backoff in the client, prevents the synchronized retry spike that the retry storm module analyses.
RateLimit header fields: status as checked
Informing clients of their quota proactively is the aim of the IETF HTTPAPI working group's Internet-Draft draft-ietf-httpapi-ratelimit-headers, which defines a RateLimit-Policy field (the quota policies a server applies) and a RateLimit field (the quota currently available). As checked on 6 November 2026, it is still an Internet-Draft, not an RFC: the working group's repository carries revision tag -11 and its source still shows the placeholder RFC number. Check the IETF datatracker for its current state, and the parameter syntax has changed between revisions, so treat the example below as illustrative of the latest draft source and re-check before shipping. The older X-RateLimit-* headers are a de facto convention that different services use with differing meanings; they are not standardized.
RateLimit-Policy: "read";q=20;w=10
RateLimit: "read";r=0;t=1
Retry-After: 1Three points from the draft matter in practice. The draft states that when Retry-After and RateLimit appear together, Retry-After takes precedence. It states that the fields convey hints, so clients must not treat remaining quota as a service-level agreement. And it warns, among its security considerations, that servers should not disclose capacity information to untrusted parties, and that counting error responses such as 401 or 403 against quota could let a malicious client probe for traffic information. A token bucket does not have a single window, so advertising q;w is an approximation; if the approximation would mislead, send Retry-After alone.
- Send
RateLimit-PolicyandRateLimitonly to authenticated callers, for the scope they own: their user or key limit, not Atlas's tenant-wide remaining quota to a user who is not an Atlas administrator. - To unauthenticated callers (stage 1), send 429 with
Retry-Afterand a generic body. Do not include policy names, tenant identifiers, limits or remaining counts. - Return the same response for an unknown API key and for a wrong secret, so rate-limit behavior does not reveal which identities exist.
- Apply jitter to retry hints and document jittered exponential backoff for clients.
Partitioning and Hot Keys: Shards, Colocation, Negative Cache and Leased Chunks
Atomicity is per instance, so capacity comes from spreading keys over shards. Two forces pull against each other: colocation makes multi-rule decisions atomic, and spreading makes load even. The skew that colocation creates is where the limiter fails first.
Sizing from the workload
Hypothetical inputs: 50,000 admission calls/s at baseline and a 10× surge to 500,000/s. If each call is a single script on one shard, and a planning budget of 40,000 script executions/s per primary is assumed (a placeholder to replace with your own load test, not a benchmark), the minimum is 500,000 / 40,000 = 12.5, so at least 13 shards. Planning 20 shards gives 25,000 calls/s on average, 62.5 % of the budget, which leaves room for imperfect hashing and headroom for failover. Combining five hierarchical rules into one same-shard script keeps it at 500,000 script calls/s, one round trip each; the same five checks against five stores would be 5 × 500,000 = 2.5 million store interactions/s.
Memory scales with active keys, not request rate: 2 million active keys at the assumed 200 B/key is 400 MB, about 800 MB per copy with headroom, about 1.6 GB for a primary and one replica. At 20 shards that is roughly 20 MB of live key data per shard (about 40 MB with the headroom) — memory is rarely the first constraint; per-shard CPU and the hottest key are.
A hot key is a load problem on attempts
With the tenant hash tag, all of Atlas's keys sit in one slot and so on one shard. If an Atlas flood attempts 75,000 requests/s (15 % of the 500,000 peak) against one key set, that shard sees 75,000 script calls per second, more than its 40,000 budget — even though Atlas is mostly being *denied*. The limiter's load is determined by attempts, not by admitted requests. Adding shards does not help, since the traffic belongs to one slot. Two mitigations apply, and they fix different problems.
function leasedAdmit(key):
l = leases[key]
if l == null:
l = { remaining: 0, expiresAt: 0, denyUntil: 0, renewing: false }
leases[key] = l
now = monotonicNow()
if l.remaining >= 1 and now < l.expiresAt:
l.remaining -= 1
return ALLOW # no store call
if l.renewing or now < l.denyUntil:
return DENY_LOCAL # one renewal in flight; negative cache
l.renewing = true
reply = storeAdmit(key, cost = CHUNK) # token-bucket script, cost = 100 tokens
l.renewing = false
if reply.allowed:
l.remaining = CHUNK - 1
l.expiresAt = now + 250ms # unspent tokens are abandoned, not refunded
l.denyUntil = 0
return ALLOW
l.remaining = 0
l.denyUntil = now + min(reply.retryAfterMs, 250ms)
return DENY_LOCALNear the limit, a bucket holding 60 tokens cannot satisfy a 100-token request even though 60 are available. A production lease handles the tail by retrying with a smaller chunk after a denial, or by granting partial chunks. Unspent tokens expire with the lease; refunding them to the store would reintroduce the double-refund problem of an ambiguous retry.
Other levers
- Dedicated or smaller placement for very large tenants. If Atlas alone is a large share of traffic, give its keys a tag with a bucket suffix such as
{t:atlas:3}and split its tenant budget into k sub-buckets, accepting that each request then sees a share of the tenant limit (approximate, with slack proportional to the split) in exchange for spreading load over k slots. - Approximate local counters for coarse protective rules, reconciled periodically, when exactness is not required.
- Pipelining independent decisions from different keys on the same connection to amortise round trips; it improves throughput but provides no atomicity between the commands.
- Cardinality controls: cap new-key creation per source before authentication, so bogus identities cannot grow the key set (see the security chapter).
Multi-Region Options and Their Honest Guarantees
Across regions, the physics changes: a coordinated decision costs a wide-area round trip, and during a partition independent regions cannot both stay available and enforce one exact global number. Each option below buys one property and sacrifices another.
Take Atlas's P-TENANT quota with a global limit G = 1,000 requests/min, served from regions EU and US. If each region enforces the full limit independently, each admits up to 1,000 in the minute: the tenant can legitimately consume 2,000/min, twice the contract. That is the baseline failure every multi-region design has to avoid.
| Option | How it works | Guarantee | Cost / what fails |
|---|---|---|---|
| A. Home-region authority | one region owns the tenant's state; other regions call it | no overshoot from the design; strict global limit while the authority is reachable | remote requests pay the WAN round trip (80–120 ms as an illustrative figure); a partition cuts the remote region off, which must deny or run a bounded fallback |
| B. Regional leases | an allocator holds the global pool and grants time-limited chunks to regions | bounded overshoot (formula below); regions admit locally at low latency | lease depletion can strand capacity; the allocator is a component to operate |
| C. Asynchronous federation | regions admit locally and exchange counts later | eventual convergence only | overshoot depends on exchange lag and traffic; no tight bound unless combined with a local cap |
| D. Bounded local fallback only | during a partition each region runs a fixed local allowance | a stated, finite excess | the allowance is the excess, and it is outside the shared accounting |
Leases in detail
The allocator holds the pool for the current minute window: 1,000 tokens. A region asks for a chunk of 100, gets a lease with a TTL of 15 s clamped to the window end, and admits locally from it. Grants never exceed the remaining pool at grant time. Clamping matters: a lease granted at 0:50 would expire at 1:05 if unclamped; clamped, it dies at 1:00, so tokens from one minute cannot be spent in the next — the carry-over term in the bound below disappears when clamping is applied.
function grant(region, leaseId, want, windowEnd, now): # runs as one atomic step at the allocator
if grants.has(leaseId):
return grants[leaseId] # retried request: same answer, no second grant
give = min(want, pool.remaining(windowEnd))
if give == 0:
return { granted: 0 }
pool.remaining -= give
expiresAt = min(now + 15s, windowEnd)
grants[leaseId] = { granted: give, expiresAt: expiresAt, epoch: allocator.epoch }
return grants[leaseId]Two failure modes of leases need their own defense. A lost or duplicated grant after a retry is why the grant is idempotent by lease id. An allocator failover that loses recent grant records can re-grant tokens that regions already hold, which becomes an overshoot term. The state of the allocator is itself a small quota store whose own replication is asynchronous unless it is built on a consensus-backed store.
Depletion and fairness
Splitting the global limit statically — 500 per region — turns demand imbalance into denial: if EU demand is 900 and US demand is 100, EU is denied 400 requests while US leaves 400 tokens unused. Demand-weighted chunk renewal fixes this: regions renew when they run low, so EU draws more chunks from the shared pool while US draws few. The unfairness left is whatever sits in leases at any instant, bounded by the chunk size times the number of regions.
The overshoot bound
The partition diagram earlier in this chapter shows these quantities for the cut-off case. Admitted traffic in a window is bounded by G plus the terms that each design choice adds: admitted ≤ G + Σ(unclamped lease carry-over across the window boundary) + Σ(local fallback allowances) + (grant records lost by asynchronous allocator failover). With clamped leases the first term is zero. Without clamping and with 2 regions, each carrying up to a 100-token chunk over the boundary, it is up to 200 (+20 % of G = 1,000). A bounded local fallback of 100 tokens per region during a partition adds up to 200. In a partition where US holds 200 unspent tokens and is cut off from the allocator, it can admit at most those 200 before the leases expire, and gets nothing new; with clamping and grants never exceeding the pool, the overshoot is zero, and the cost is that up to 200 tokens (20 % of G) are stranded and the tenant's US users are denied once they run out. The bound is therefore a *design output* that can be tuned with chunk size, TTL and fallback, and stated in the contract.
Control Plane: Versioned Policies, Staged Rollout and Consistent Application
The data plane makes millions of decisions per second against a policy that changes a few times a day. Keeping the two separate lets policy be audited, validated and rolled back without touching the request path, and it gives every decision a policy version to be explained by.
{
"bundleVersion": 42,
"publishedAt": "2026-11-06T09:30:00Z",
"checksum": "sha256:…",
"rules": [
{ "id": "read-user-v3", "scope": "user", "routeGroup": "read",
"algorithm": "token_bucket", "capacityMillis": 20000, "refillMillis": 2000, "periodMs": 1000, "costMillis": 1000,
"failMode": "local_bounded", "idempotent": false },
{ "id": "tenant-atlas-minute", "scope": "tenant", "tenant": "atlas",
"algorithm": "token_bucket", "capacityMillis": 1000000, "refillMillis": 1000000, "periodMs": 60000, "costMillis": 1000,
"failMode": "local_bounded", "idempotent": false },
{ "id": "export-tenant", "scope": "tenant", "routeGroup": "export",
"algorithm": "token_bucket", "capacityMillis": 1000000, "refillMillis": 1000000, "periodMs": 60000, "costMillis": 10000,
"concurrencyMax": 3, "failMode": "closed", "idempotent": true }
]
}What validation has to catch
- cost ≤ capacity and refill > 0 for every token-bucket rule (the script's guards are the last line, not the first)
- all numeric fields within the integer bounds, and units consistent (millitokens, milliseconds)
- rule sets for one request map to the same hash tag, are at most a handful long, and contain no duplicate keys
- a fail mode is explicit for each rule; a rule without one does not publish
- exemption rules carry their own caps and an owner; a rule that matches everything or has no cap is rejected
Rollout and distribution
A bundle is an immutable snapshot with a monotonically increasing version and a checksum. The control plane stages it: validate, replay recent traffic samples against it offline to estimate the denial rate by rule, release to a small slice of gateways or a canary tenant, observe the deny rate and fallback metrics, then release to everyone. Gateways keep the previous bundle while they fetch the new one and switch atomically per snapshot; one request is evaluated against one bundle from start to finish, and the version number is written into the decision.
Two sources of staleness are separate and should be reasoned about separately: stale policy (a gateway still evaluating bundle 41) and stale counter (store state that does not reflect recent decisions, from replication lag or restore). Tightening a limit propagates in the time of the slowest distribution path, typically a push plus a poll interval; a gateway that is disconnected from the control plane keeps evaluating its last bundle indefinitely unless it is told to expire it (I13). If instant revocation matters for a rule, give snapshots an explicit maximum age and a conservative behavior after it.
Rollback is not decrementing the version number. A rollback publishes a *new* bundle whose content equals an older one, with a higher version. Gateways accept only versions greater than the one they hold, so a delayed old snapshot arriving late cannot overwrite a newer one. Because limits ride as script arguments and stored tokens are clamped to the capacity in force, a rollback or tightening takes effect on the next call for existing buckets, with no key migration.
Operational Telemetry: Decisions, Store Health, Overshoot and Fairness
An operator needs to answer, in order: is the limiter itself healthy, is it deciding the way the policy says, and is anyone being treated unfairly. The metrics that answer each question are different, and none of them is the ledger of record.
| Signal | Dimensions kept | Question it answers | Typical alert shape |
|---|---|---|---|
| Decision rate by outcome | allowed / denied, rule id, policy version, route group | are denials where the policy says they should be? | sudden change in denial ratio after a bundle version change |
| Per-rule deny rate by tenant | tenant (bounded set), rule | is one tenant dominating denials, is a new rule too tight? | a tenant above N % of all denials; Birch denied while under its limit |
| Limiter latency and errors | shard, outcome; p50/p99; timeouts; pool wait | is the store on the request path healthy? | p99 above the design goal for several minutes, not single spikes |
| Breaker state, fallback decisions | shard, fail mode, reason | how often are decisions degraded? | any sustained fallback rate for strict routes; every fail-closed activation |
| Store health | memory used vs limit, eviction and expiry counters, key count, replica lag, script duration, connected clients | can the store keep enforcement correct? | any eviction on the limiter store; memory growth beyond planned headroom; replica lag past the loss-window budget |
| Policy distribution | bundle version per gateway, snapshot age | are all gateways on the same policy? | gateways behind by more than the propagation target |
| Lease and region accounting | lease grants, depletion events, stranded tokens, allocator reachability | is regional capacity healthy and fair? | a region repeatedly depleted while the global pool has tokens |
| Cardinality | new keys/s by source class, active-key count | is someone growing state? | new-key rate spike from a pre-auth source |
| Overshoot estimate | sampled admitted counts per rule per window vs the limit | is enforcement honouring the contract? | admitted exceeding the stated bound plus tolerance |
Overshoot cannot be read from the store, which only knows its own authority's view. It is estimated offline: take sampled decision logs from all regions, group by rule and by true window, and compare admitted counts with the limit plus the documented bound of the chosen design. A consistent excess above the bound is a bug in the design or the implementation.
Cardinality has to be budgeted like any other resource. Tenant, rule and route group are bounded and safe dimensions; raw user ids, API keys and IP addresses are not safe as metric labels or in logs. Log the keyed principal hash and a decision id, and keep the sampling rate for allowed decisions low and for denials and fallbacks high.
Security and Trust Boundaries
A rate limiter makes a decision about someone, so the first question is who: and the identity it keys on must come from something the caller cannot choose. Most limiter bypasses come from trusting an identity or a header the caller can forge.
| Boundary | Trusted source | Not trusted | Practical rule |
|---|---|---|---|
| Authenticated identity | signature-verified credential claims or a server-side lookup of the API key | a user id or tenant id supplied as a header or body field | derive tenant and principal after verification, from the credential only |
| Client IP | the address of the connection from your own edge, or the forwarded address added by a proxy you control, at a known chain position | any X-Forwarded-For value the caller sent | the edge strips inbound forwarding and identity headers and sets its own; count trusted hops from the right |
| Tenant boundary in the store | the key prefix {t:<tenant>} built from the verified tenant id | tenant ids that contain structural characters | charset-restricted tenant ids; the principal part is a keyed hash, so it cannot inject delimiters or tags |
| Policy source | signed, versioned bundle from the control plane | a policy file edited on a gateway | gateways accept only bundles with a valid checksum and a higher version |
| Control plane access | role-based: author, approver, deployer | shared admin credentials | separate roles; break-glass is logged and expires |
Before and after authentication
Pre-authentication limiting exists to keep the front door from being used as a cheap way to create state or burn CPU. It keys on IP, uses a small, TTL-bound state (the fixed window of P-ANON), and caps new keys per source. Failed authentication attempts count against that IP budget, not against the tenant the attacker claims, so a stranger who guesses Atlas's key id cannot drain Atlas's quota. Post-authentication limits key on verified principals. A request that fails authentication never touches a tenant bucket, which is also why attackers cannot use fake credentials to consume the state of real tenants. The cost to be honest about: IP-only limits are not a fairness mechanism, since many users behind one carrier-grade NAT share an address and a determined attacker rotates addresses cheaply. They defend the front door and nothing else.
Least privilege and privacy
- Give the gateways a Redis identity that can run only the commands the limiter scripts need, on the limiter's key namespace, and nothing administrative; use authenticated, encrypted connections between gateways and stores.
- Store only keyed hashes of principals in keys, logs and metrics. Raw API keys, user ids and IP addresses never appear in key names or log lines.
- Treat the pepper used for key hashing as a secret with rotation: rotating it re-keys all state, so schedule rotation as a version change.
- Run the privileged-operations and health-probe path as a separate route with its own authentication, its own small cap and its own audit log (I14); a bypass that is unmetered is a flood channel.
Invariants and Assumptions: What Is Promised and What It Depends On
Each guarantee in this design holds under named conditions. The table states them so that a claim such as "no double admission" is never read as broader than the mechanism behind it.
| ID | Invariant | Holds only when | Where it is enforced, and what breaks it |
|---|---|---|---|
| I1 | One atomic authority cannot admit two contending requests against the same remaining budget | the state and operation are valid and both requests reach the same authority | the single script on the shard owning the key; says nothing about other regions or a second store |
| I2 | Multi-key enforcement is atomic only if all keys share a transaction boundary | every key of a request carries the same hash tag | the multi-rule script and the control-plane validation; otherwise leases or compensation with bounded inconsistency, tested |
| I3 | Every decision is attributed to a policy version and a trusted principal scope | identity comes from verified credentials, never from a header the caller controls | policyVersion and deniedScope in the decision; the trust-boundary table |
| I4 | A token bucket admits within configured burst and average rate; it is not a strict sliding window | — | the 40-in-10-s example for P-READ; lower capacity or add a stricter rule when the downstream cannot take the burst |
| I5 | Retry-After is a hint, not a reservation | — | documented in the contract; jitter added; clients told to back off |
| I6 | A store timeout must not silently double-debit or double-refund | calls with ambiguous outcomes carry a stable, tenant-scoped decision id with bounded retention; otherwise at most one unit is accepted and no blind refund is issued | the idempotency record in the multi-rule script; retention expiry reintroduces a bounded residual risk |
| I7 | Script atomicity is per instance, not durability or cross-failover linearizability | — | asynchronous replication, restore and eviction can all produce a fuller bucket; bound = that bucket's capacity |
| I8 | Regional quotas are coordinated by one authority or granted by bounded leases; never zero overshoot and unrestricted partition availability together | — | home-region authority for strictness; clamped leases for locality with the overshoot formula |
| I9 | Limits do not replace authorization, anti-abuse, downstream protection or client discipline | — | the limiter composes with those systems and does not substitute for them |
| I10 | Every buffer, timeout, retry budget, key set, lease and fallback is finite and has overload behavior | limits are configured and validated | deadline and pool bounds, TTL on every key, lease expiry, bounded fallback buckets and decision-record retention |
| I11 | Allow does not mean backend success | — | admission only; a downstream 429 or 503 can follow an allowed decision |
| I12 | Metrics are not an authoritative ledger | — | lossy telemetry for dashboards and alerts; contractual accounting needs its own path |
| I13 | Policy changes have explicit version and freshness semantics | snapshots carry a version, and gateways enforce a maximum age where revocation matters | monotonic versions; no promise of instant revocation for a disconnected gateway |
| I14 | A privileged bypass is narrow, separately authenticated and audited | — | separate route with its own cap; an unmetered bypass is a flood channel |
| I15 | Every numeric illustration states its assumptions and has been arithmetically checked | — | units, windows, caps and key counts are stated at each use; all figures are hypothetical teaching inputs |
Two assumptions underlie several rows and deserve a name. The first is a single, correctly operated store authority per key set: one primary per shard, with failover that may lose a bounded tail of acknowledged writes. The second is bounded clock misbehaviour on that authority: wall-clock steps are possible but not unbounded, which is what the clamps in the script absorb.
End-to-End Request Trace: Atlas, K1 and U-17
A worked trace ties the earlier chapters together. All values are hypothetical: policy bundle 42, epoch milliseconds are shown as offsets from T₀, and the Atlas tenant bucket is assumed to hold 640,000 millitokens at the start.
| # | Step | What happens | State and numbers |
|---|---|---|---|
| 1 | Edge, pre-authentication | The edge strips caller-supplied forwarding and identity headers, derives the client IP from its own connection, and runs the P-ANON fixed window for that IP | count 7 of 20 in the current 10 s window; passes |
| 2 | Authentication | Gateway A verifies key K1, resolves tenant Atlas and user U-17; a bad key would stop here with 401 | tenant atlas, principal U-17, route group read (GET /catalog) |
| 3 | Authorization and policy lookup | U-17 may call /catalog; gateway A evaluates bundle 42: user rule P-READ, tenant rule P-TENANT, leased global P-GLOBAL | three applicable rules; policyVersion 42 |
| 4 | Local global lease | Gateway A spends one of its leased P-GLOBAL tokens without calling the store | chunk 100 → 99 remaining; no store call |
| 5 | Key derivation | Principal hashed with the pepper; both bucket keys carry {t:atlas} | rl:v3:{t:atlas}:u:7c1e…:read and rl:v3:{t:atlas}:tenant:atlas:all; same slot, shard R |
| 6 | Atomic decision, request 1 | One script call from A: user bucket 20,000 → 19,000; tenant bucket 640,000 → 639,000; both TTLs refreshed (15 s and 65 s) | returns {1, 0, 0, 19000, 639000}; A forwards the request |
| 7 | Contention at T₀ + 8,000 ms | U-17 has 1,000 millitokens left. Gateways A and B each send a request in the same millisecond. Atlas's bucket holds 639,000 at that moment, after refill and other traffic. R runs A's script first: user 1,000 → 0, tenant 639,000 → 638,000. R then runs B's script | A's reply {1, 0, 0, 0, 638000}; B's reply {0, 1, 500, 0, 638000}: denied by the user rule, nothing debited from the tenant bucket |
| 8 | Response for B | retryAfterMs 500 → max(1, ceil(0.5)) = Retry-After: 1; 429 to an authenticated caller, with RateLimit fields scoped to U-17's own limit; B also denies locally for U-17 for up to 250 ms | exactly one of two contending requests admitted; no double spend |
| 9 | Refill | The client retries after about one second with jitter | delta = 1,000 ms → floor(1,000 × 2,000 / 1,000) = 2,000 millitokens → request admitted, bucket 1,000 |
| 10 | Export request, lease first | U-17 posts to /reports/export; gateway A mints decision id r-e41, acquires an export lease for Atlas (2 of 3 in flight → 3 of 3), then calls the multi-rule script with cost 10,000 and a 30 s decision record | lease granted; the tenant bucket is debited 10,000 millitokens if the rate rule allows |
| 11 | Ambiguous timeout | A's script call times out after the command was sent; A cannot tell whether it ran. The call is idempotent, so A retries with the same decision id | the script finds the record for r-e41 and returns the stored result; the tenant bucket is debited once, not twice |
| 12 | Fourth concurrent export | Another export arrives while 3 are in flight: the lease script returns {0, 3} | 429 with a policy-chosen Retry-After, even though the rate bucket has budget; weighted cost and concurrency are separate gates |
| 13 | Store loss during a later request | If shard R were unreachable: P-READ falls back to the local bucket (capacity 5, 0.25/s); the export route fails closed with 503 | fallback: true in the decision; metrics record the degraded mode |
Reading the trace against the invariants: step 7 is I1 in action — one authority serialises the two contenders — and step 8 shows that the losing request consumed nothing; step 11 is I6, the bounded-retention decision id preventing a double debit; step 12 is the reason concurrency is a distinct mechanism; and step 13 is I8 and I10 in miniature, with explicit bounds instead of an unlimited fall-open. The diagrams on this page cover the parts that are easiest to get wrong, and the Complete Design page shows the same components assembled step by step.
- 01A distributed limiter works by making the read, refill, check and debit one operation on a single authority, with its own clock, rather than by sharing counters between gateways.
- 02Token bucket is the primary algorithm: integer millitokens, store time, delta and TTL clamps, deny-without-consuming, and retry hints rounded up to integer seconds.
- 03Multi-rule decisions are atomic only when all keys share a hash slot; across slots the options are leases or idempotent compensation, never blind refunds.
- 04Atomic does not mean durable: failover, restore and eviction can make a bucket fuller, and the bound is that bucket's capacity.
- 05Hot keys are a problem of attempts and of admits, and they need different fixes: a negative cache for the first and leased chunks for the second.
- 06Across regions pick a side: a single authority for strictness, or clamped leases with a stated overshoot bound; every other claim needs its assumptions spelled out.