Skip to content
SeeItFlow
System Design · 18 chapters · 7 decisions · ~72 min read

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.

Mangesh MandlikLast updated: How lessons are made
Chapter 01

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.

jsonIllustrative request shape; field names are this module's convention, not a standard
// 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

jsonThe decision is a structured record first and an HTTP response second
// 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
}
Swipe to pan
Rate-limit decision contract and HTTP mappingThe Decision ContractWhat the gateway asks, what the limiter answers, how it surfaces over HTTPDecision requesttenant: "atlas"principal: "U-17"routeGroup: "read"cost: 1policyVersion: "v3"principal comes from verified auth,never an unchecked client header.check()Decision response (example: denied)allowed: falsepolicyId: "P-READ"limit: 20remaining: 0retryAfter: 1 (seconds)reason: "bucket_empty"decisionId: "d-8f31…"OutcomeHTTP statusNotesallowed200 (from the backend)optional rate-limit headers; allow ≠ backend successdenied429 Too Many RequestsRetry-After: 1 — a hint (RFC 9110), not a reservationlimiter unavailable503 (or fail-open by policy)a different failure from 429; fail mode is chosen per ruleHeader fields: know the statusRateLimit / RateLimit-Policy are an IETFInternet-Draft, not an RFC. X-RateLimit-* is ade-facto convention. 429 is RFC 6585.Do not leak quotasBefore authentication, answer with a plain 429.No tenant limits, remaining counts or exactreset times for unauthenticated callers.
The admission call and the decision fields that cross the gateway boundary

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 outcomeHTTP statusRetry-AfterNotes
Allowedpasses through to the backendnoneAdmission only; the backend can still answer 4xx or 5xx (invariant I11)
Rate or concurrency exceeded429integer seconds, rounded up from retryAfterMsA hint, not a reservation (I5); omit when retryAfterMs is -1 and alert on the policy bug
Limiter unavailable, policy fails closed503a conservative policy-chosen valueDependency failure, not a quota violation; never labeled 429
Limiter unavailable, bounded local fallback exhausted429from the local bucketExplicit degraded mode; fallback is true and the metric says so
Credentials missing or invalid401noneDecided before the principal quota is consulted
Authenticated but not permitted403noneDecided before the principal quota is consulted
Status semantics for each decision outcome; HTTP details are expanded in the HTTP behavior chapter
Chapter 02

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.

textAnatomy of the key for user U-17 under tenant Atlas, GET /catalog route group
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)
└─ namespace
Swipe to pan
Key anatomy and TTLKey Anatomy, Hash Tag and TTLOne bucket per (policy version, tenant, principal, route group)rl:namespacefeature prefix; keepslimiter keys apart fromother data in the storev3:policy versiona new rule set gets newkeys, so old and newstate never mix{t:atlas}:hash tag: tenantonly text in { } picks thecluster slot: all Atlaskeys share one slotu:U-17:principalverified identity; hashit for privacy, e.g.u:9f2c41ab (example)readroute groupread vs export: eachgroup has its ownpolicy and counterTTL = time to full refill + slackP-READ: 20 tokens ÷ 2 tokens/s = 10 s to refill from empty; plus 5 s slack (example) → TTL 15 s.An expired key is recreated full, which equals a fully refilled bucket, so TTL must never be shorter than 10 s.Set the state and its EXPIRE in the same script. Eviction under memory pressure can also reset counters silently.Privacy: hash principals; keep raw emails, API keys and tokens out of key names, logs and metrics.
Key anatomy and the lifetime of a bucket from first request to idle expiry

The 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.

AlgorithmRedis typeFields or membersState sizeExpiry
Fixed windowhashw (window start ms), n (count)O(1)time left in the window
Sliding logsorted setmember = unique request id, score = admit time msO(requests in window)the window length, refreshed on write
Sliding counterhashw, cur, prevO(1)2 × window
Token buckethashtokens (millitokens), ts (ms)O(1)full-refill time + slack
GCRAstringone integer: theoretical arrival timeO(1)tat − now
Concurrency capsorted setmember = lease id, score = lease expiry msO(in-flight)lease TTL
State shapes: a hash holds a few small fields; a sorted set is chosen only when the algorithm needs ordered members
Chapter 03

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.

Swipe to pan
Fixed-window counter state over timeFixed Window: One Counter Key per WindowP-ANON: 20 requests / 10 s per IP · window id w = floor(epoch / 10 s) · illustrative IPTimeEventCounter key stateDecision0.0 sreq #1 (creates key)rl:anon:203.0.113.9:w0 = 1 · TTL 12 sallow0.1–9.8 sreqs #2 … #20rl:anon:203.0.113.9:w0 = 20allow (20 of 20)9.9 sreq #21rl:anon:203.0.113.9:w0 = 21deny → 42910.1 sreq #22 (new window)rl:anon:203.0.113.9:w1 = 1 · TTL 12 sallow12.0 sw0 TTL elapsedrl:anon:203.0.113.9:w0 deleted—First-create raceINCR then EXPIRE as two commands: if theclient dies in between, the key has no TTL.Fix: do INCR and “if result = 1 then EXPIRE”inside one script (one atomic step).Why it stays in the designOne key and one INCR per request: tiny, cheap,fine for coarse rules such as anonymous IPs.TTL = window + slack (10 s + 2 s here) onlycleans up; the window id is what resets.
One counter per window: state, the reset at the boundary and the TTL that follows it

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.

luaFixed-window script (Lua, millisecond units). Illustrative; review and test against your Redis version
-- 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.

Chapter 04

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.

Swipe to pan
Sliding log timelineSliding Log: One Timestamp per RequestWindow W = 10 s · limit 20 · exact, but memory is O(requests in window)window (2.0 s, 12.0 s] — slides with nownow = 12.0 s02468101214s1.5evicted3.14.88.09.211.4Per request (atomically)1. Evict entries ≤ now − 10 s (drops 1.5)2. Count what remains: 5 entries3. 5 < 20 → allow, append 12.0Typically a Redis sorted set: score = timestamp.Counts every request in any 10 s span exactly.Memory costAtlas 1,000 / min → up to 1,000 entries per key.20,000 active users × 20 entries= 400,000 entriesRejected requests need not be logged, but a highlimit means a long list per key to evict and count.
Admitted requests as ordered timestamps: trimming old entries, counting, and finding the retry moment

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.

luaSliding-log script. Member = decision id, so a retry of an already admitted request is recognised instead of counted twice
-- 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.

Chapter 05

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.

Swipe to pan
Weighted sliding-window counter (approximate)Weighted Sliding Counter: A Cheap EstimateW = 10 s · limit 20 · two counters per key instead of a logsliding window [2.5 s, 12.5 s)prev [0, 10): 1875% overlap (7.5 s) → weight 0.75ignoredcurr [10, 20): 5counted in full so far0 s10 s20 snow = 12.5 s (elapsed 2.5 s)est = prev × (1 − elapsed / W) + curr= 18 × (1 − 2.5 / 10) + 5= 18 × 0.75 + 5 = 13.5 + 5 = 18.518.5 + 1 = 19.5 ≤ 20 → allow (curr becomes 6)APPROXIMATEAssumes the 18 earlier requests werespread uniformly over the previouswindow. A late-window burst is undercounted.Cost: 2 counters per key, O(1) per request. It removes most of the fixed-window boundary spike withoutstoring a log, but it is an estimate, never an exact sliding window.
The previous window's weight shrinks as the current window advances; the estimate is the sum of both parts

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.

luaSliding-counter script; rolls the window forward from stored state and stores only O(1) fields
-- 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.

Chapter 06

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.

Swipe to pan
Token bucket refill timelineToken Bucket: Burst, Drain, RefillP-READ: capacity 20 · refill 2 tokens/s · cost 1 · tokens vs time01020capacity 20024681012s101234① t=0 s: bucket full, 20 requests allowed → 0② 21st denied: retryAfter = ceil(1/2) = 1 s③ t=5 s idle: 0 + 2×5 = 10 tokens④ t=10 s idle: 0 + 2×10 = 20, capped at 20Fractional tokens: store integersKeep millitokens: capacity 20,000 m, refill 2,000 m/s.300 ms idle → 2,000 × 0.3 = 600 millitokens.Avoids float drift; one state: (tokens, last time).Not a strict sliding windowOver any T seconds it can admit up to 20 + 2T:40 in 10 s (full burst + 10 s of refill), while thefixed rule above says 20 per 10 s. Both are valid.
Bucket level over time for P-READ: a full bucket drains in a burst, then refills at 2 tokens per second

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

TimeEventTokens afterResult
t = 0, idle bucketbucket starts full20—
t = 020 requests arrive0all 20 allowed
t = 021st request0denied; 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 consumptionrefill 600 millitokens0.6a request here is still denied; 400 millitokens short → 200 ms
t = 5 s, no consumptionrefill 2 × 51010 requests allowed
t = 10 s idlerefill reaches the cap20capped at capacity; more idle time adds nothing
Token bucket P-READ: capacity 20, refill 2 tokens/s, cost 1. Idle times assume no consumption in between

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

ConcernRule in the scriptWhy
Non-numeric, NaN, fractional or negative inputsall of capacity, refill, period and cost must be integers in [1, 2^40], slack in [0, 2^40], and capacity × period at most 2^52Lua'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 capacityreturn retry −1 without touching stateWaiting cannot help; this is a policy defect to alert on
Refill rate 0rejected as a policy errorA 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 backwardsdelta = 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 forwarddelta is clamped to the time needed to fill the bucketWorst case one free refill to capacity, no overflow
Time sourcethe store's own TIME, in the same scriptGateways disagree on the time by milliseconds to seconds; a single authority gives every request the same clock
Stored tokens above a lowered capacitytokens = min(capacity, …) on every callAn emergency tightening applies immediately to existing buckets
Roundingfloor on refill, ceiling on retryLoses less than one millitoken per allowed request, in the strict direction; retry hints are never optimistic
Defensive rules the script applies; each protects a specific failure rather than adding generic checks
Chapter 07

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.

Swipe to pan
Token bucket vs leaky bucket queue vs GCRAThree Mental Models, Two BehaviorsPolicing (reject) vs shaping (delay) · GCRA is a formulation, not a third behaviorToken bucketpolicing: admit now or reject20 tokens+2 / sempty → 429, no waiting• Burst up to capacity 20• Then average 2 requests/s• State: tokens + last-refill time• Chosen for P-READLeaky bucket (queue)shaping: delay to smoothFIFO queue2 / s steadyqueue full → drop• Constant output rate• Excess requests wait in line• Needs queue memory, adds delay• Not what a 429 limiter doesGCRA (meter)metering: one stored timeTATtheoretical arrivalallow if now ≥ TAT − τ• Emission interval T = 0.5 s at 2/s• τ sets the burst tolerance• Allow: TAT = max(TAT, now) + T• One timestamp, no queue, no refillReading the three togetherGCRA is the leaky bucket used as a meter, stated as arithmetic on one timestamp; it is an equivalentformulation, not a queue. With matching parameters it classifies traffic like a token bucket, so the choice isabout state shape and ergonomics. The leaky bucket as a queue is different: it delays instead of rejecting,which suits traffic shaping, not an API that must answer 429 quickly.
Token bucket, leaky bucket as a meter and GCRA describe the same admission rule; a shaping queue is a different component

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.

textGCRA decision as conceptual pseudocode; the Redis version would wrap it in a script like the token-bucket one
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 - now

Run 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.

PropertyToken bucket (hash of two fields)GCRA (one integer)
Statetokens + last-update timestamptheoretical arrival time
Refill computedon each request, from elapsed timeimplicit in the max(TAT, now) step
Natural to report remaining tokensyesderivable: (B − (TAT − now)) / T
Fractional ratesexact with the (refill, period) pairneeds T as an integer; rounding or finer units (µs) otherwise
Weighted costcost × millitokenscost × T
Observability and debuggingfields are readable and self-explanatoryopaque timestamp; harder to eyeball
Both are policing meters with the same admit set when parameters correspond; GCRA is an optional re-formulation, not a new capability
Chapter 08

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

textConceptual reference pseudocode. Not executable against a store as written
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

luaAtomic token bucket in Lua. Single key, millitokens and milliseconds. Illustrative; review, load with SCRIPT LOAD and test on your own Redis version
-- 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. ts is never taken from the caller, and ts = max(ts, now) keeps the stored timestamp from moving backwards.
  • Keys and arguments are separated. The bucket key is passed in KEYS, limits in ARGV; 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.
Swipe to pan
Redis failover and replica lagAtomic is not durable: failover forgets recent countsIllustrative: tenant minute counter, limit 1,0001 · Before failoverR (primary)acks writesR2 (replica)behindasync streamcounter 700(acked to callers)counter 64060 not yet copied2 · R fails, R2 promotedR downunreachableR2 primarypromotedfailovercounter 64060 increments gone3 · Enforcement loosenstrue used 700 → 300 leftR2 believes 640 → 360 leftUp to 60 extra admits in thiswindow, at most the lost writes.bars scaled to the 1,000 limitAtomic ≠ durableA script is atomic on one instance;replication is async, so an ackedwrite can be lost. WAIT narrows thewindow, not strong consistency.Persistence restoreRestoring an RDB or AOF snapshotcan rewind counters to an olderstate: the same looser effect, overa longer gap.Evictionmaxmemory eviction silentlydeletes counters, which silentlyresets enforcement. Use TTLs andwatch evicted-key metrics.
Atomic on the primary, asynchronous to the replica: a failover can lose acknowledged decisions

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.

Chapter 09

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.

Swipe to pan
Same-slot versus cross-slot atomicity in Redis ClusterMulti-key atomicity stops at the slot boundaryRedis Cluster: a script may only touch keys that hash to the same slotSame slot: hash tag {t:atlas}one script: check all rules, debit all or noneShard 1 · slot Xrl:v3:{t:atlas}:u:U-17:readrl:v3:{t:atlas}:tenant:minatomic on one instanceDifferent slots: no shared tagone script naming both keysShard 1 · slot Arl:{t:atlas}:tenantShard 2 · slot Brl:{u:U-17}:readCROSSSLOT error · nothing runsOption 1 · Colocate with a hash tagAll rules of a tenant share one slot, so onescript stays atomic.Cost: a hot tenant is a hot slot.Cost: global rules cannot share every tenant tag.Option 2 · Reserve, compensate, idempotentDebit shard 1, then shard 2; refund shard 1 ifshard 2 denies, keyed by a stable request ID.Not atomic: between the steps another caller seesthe debit, and a failed refund leaves a bounded error.
Keys whose hash tag is the tenant share a slot and can run in one script; untagged keys land in different slots and are refused

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.

luaMulti-rule token bucket: all keys in one slot; all-or-nothing debit; optional idempotency record. Illustrative; review and test before use
-- 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 result

Walk-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.

Swipe to pan
Rate, weighted cost and concurrency comparedThree different questions a limit can askHow fast? How expensive? How many at once?1 · Ratetoken bucket2 · Weighted costexport = 10 units3 · Concurrencysemaphore / lease14 / 20 tokensneeds 10 · has 3 · deficit 7#1#2#3#4 ✕3 in flight · rate tokens 14/20Limits arrival rate over timeAdmit if tokens ≥ cost (1 per read)Capacity 20, refill 2 per second429 + retryAfter 1 s when emptySame bucket, bigger priceAn export costs 10, a read costs 1deficit 7 ÷ 2 per s = 3.5 sretryAfter 4 s (rounded up)Limits simultaneous workMax 3 exports in flight per tenant4th denied though rate tokens remainSlot frees when an export endsNeighbors that look similar but answer different questionsQueue shaping (a leaky bucket used as a queue) delays work to smooth output; these limiters admit or reject at once.Billing and metering need an auditable ledger; lossy decision telemetry is not one. A WAF inspects request content.A request-count quota alone cannot protect against costly calls: weight by cost and cap concurrency.
Weighted cost spends units per request; a concurrency cap bounds simultaneous work; both are needed for exports

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.

luaConcurrency lease: sorted set of lease ids scored by expiry; acquire is idempotent for the same lease id
-- 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.

Chapter 10

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 occursWhat is knownSafe action
Before any bytes are sent: breaker open, pool exhausted, connect refusedThe script did not runDegrade per policy; a retry is safe
NOSCRIPT reply to EVALSHAThe 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 stateTreat as a policy fault; do not retry; alert
Timeout or connection reset after the command was sentUnknown: the script may have run and debitedRetry only with an idempotency record; otherwise degrade and accept at most one extra unit
The decision tree for a store call; the last row is the ambiguous outcome

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.

textClient call path. `phase` and `conn` are assigned before the try block so the catch branch can always read them
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

PolicyStore unavailableReasonBound on admitted excess
P-READ, per-user cheap readsbounded local fallback bucket per gateway: capacity 5, refill 0.25/sAvailability matters and the backend is separately protectedA 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, expensivefail closed: 503 with a conservative Retry-AfterAn unbounded export burst is costlier than refusing exports for a while0 additional exports admitted
P-ANON on /loginbounded local fixed-window per gateway, small capLocking everyone out of login is itself an outage; downstream credential throttling remainsgateways seeing the IP × local cap per window
P-GLOBAL leased chunksspend what is held (≤ 100 tokens per gateway, valid ≤ 250 ms)The lease is already a local budgetFleet-wide parked tokens ≤ 100 × 100 = 10,000 = 0.25 s of global capacity
Fail-mode matrix by endpoint criticality. Local limits are hypothetical teaching values
Swipe to pan
Backoff, retry budgets and load sheddingThe same 10,000 retries, with and without jitter10,000 clients all received “Retry-After: 1”; arrivals per 100 ms bucketSynchronized hint10,000in one instant0 s1 s2 s3 sstill denied → everyoneretries together againJittered exponential backoff1,000 per 100 ms≈ 10,000/s average0 s1 s2 s3 sClient: jittered backoffdelay = random(0, min(cap,base × 2^attempt)), with theserver’s Retry-After as a floor.Random delays de-synchronizecallers.Client: retry budgetRetries are capped to a smallfraction of the client’s normaltraffic, and stop after a fixednumber of attempts, so a longoutage cannot multiply load.Server: load sheddingWhen the limiter or backendsaturates, drop low-priority workearly (503) instead of queueingwithout bound. Shedding protectsthe service retries would crush.
Client backoff with jitter and server-side shedding keep denied traffic from amplifying itself

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.

Chapter 11

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

StageCheckRejectionWhy here
1. Edge, before authenticationcoarse per-IP limit (P-ANON, fixed window)429 with Retry-AfterProtects authentication itself from floods and from credential guessing; state is keyed by IP and tightly bounded
2. Authenticationverify the credential; derive tenant and principal401Quotas are keyed by verified identity; failed attempts are counted against the stage-1 IP budget, not against any tenant
3. Authorizationmay this principal call this route?403A 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 quotathe rules of the previous chapters: user, route, tenant, concurrency429 with Retry-After, or 503 if the limiter is unavailable and the policy fails closedOnly authenticated, authorized traffic spends per-tenant state
5. Backendhandler runsits own 2xx, 4xx or 5xxAdmission is not a delivery guarantee (I11)
Pipeline order. Stage 1 exists because stage 2 can be attacked; stage 4 exists because tenants share a backend

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.

httpA 429 for an authenticated caller. Response body structure follows the problem-details convention; fields beyond the standard ones are this module's choice
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.

httpAuthenticated 429, in the syntax of the draft source checked on 6 November 2026 (q = quota, w = window seconds, r = remaining, t = seconds until more is available). A token bucket maps imperfectly onto a window-style policy
RateLimit-Policy: "read";q=20;w=10
RateLimit: "read";r=0;t=1
Retry-After: 1

Three 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-Policy and RateLimit only 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-After and 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.
Chapter 12

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.

textGateway-side chunk lease. Single-flight renewal and a bounded expiry keep parked tokens finite; production code needs a lock or atomic per-key state
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_LOCAL

Near 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).
Chapter 13

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.

OptionHow it worksGuaranteeCost / what fails
A. Home-region authorityone region owns the tenant's state; other regions call itno overshoot from the design; strict global limit while the authority is reachableremote 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 leasesan allocator holds the global pool and grants time-limited chunks to regionsbounded overshoot (formula below); regions admit locally at low latencylease depletion can strand capacity; the allocator is a component to operate
C. Asynchronous federationregions admit locally and exchange counts latereventual convergence onlyovershoot depends on exchange lag and traffic; no tight bound unless combined with a local cap
D. Bounded local fallback onlyduring a partition each region runs a fixed local allowancea stated, finite excessthe allowance is the excess, and it is outside the shared accounting
Four multi-region approaches. Option A for strictness, B for locality; C and D are fallbacks and complements rather than primary designs for hard limits
Swipe to pan
Partition and overshoot boundsWhat each design can admit in one window, worst caseG = 1,000/min, two regions; bars scaled to tokens (illustrative partition)G = 1,000Naive: independent regions2×: up to 2,000/minEU ≤ 1,000US ≤ 1,000Leases clamped to windowovershoot 0, ≤ 200 strandedadmitted ≤ 800strandedUnclamped carry-overup to +200 (+20%)G = 1,000+200Bounded local fallbackup to +200 (+20%)G = 1,000+200General boundadmitted_in_window ≤ G + Σ unclamped lease carry-over + Σ local fallback allowances + grant records lost in allocator failoverTwo regions: carry-over ≤ 100 each → ≤ +200 (+20%); fallback 100 each → ≤ +200.Strict home region: zero overshoot, but the remote region pays WAN RTT and loses admission in a partition.
A partition strands unspent leases: what each region can still spend, and what the bound says about the excess

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.

textAllocator grant. The lease id makes retries idempotent; the epoch lets regions ignore grants from a deposed allocator. Illustrative, single authority per pool
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.

Chapter 14

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.

jsonA policy bundle: immutable once published; a rule belongs to exactly one bundle version
{
  "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.

Chapter 15

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.

SignalDimensions keptQuestion it answersTypical alert shape
Decision rate by outcomeallowed / denied, rule id, policy version, route groupare denials where the policy says they should be?sudden change in denial ratio after a bundle version change
Per-rule deny rate by tenanttenant (bounded set), ruleis 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 errorsshard, outcome; p50/p99; timeouts; pool waitis the store on the request path healthy?p99 above the design goal for several minutes, not single spikes
Breaker state, fallback decisionsshard, fail mode, reasonhow often are decisions degraded?any sustained fallback rate for strict routes; every fail-closed activation
Store healthmemory used vs limit, eviction and expiry counters, key count, replica lag, script duration, connected clientscan the store keep enforcement correct?any eviction on the limiter store; memory growth beyond planned headroom; replica lag past the loss-window budget
Policy distributionbundle version per gateway, snapshot ageare all gateways on the same policy?gateways behind by more than the propagation target
Lease and region accountinglease grants, depletion events, stranded tokens, allocator reachabilityis regional capacity healthy and fair?a region repeatedly depleted while the global pool has tokens
Cardinalitynew keys/s by source class, active-key countis someone growing state?new-key rate spike from a pre-auth source
Overshoot estimatesampled admitted counts per rule per window vs the limitis enforcement honouring the contract?admitted exceeding the stated bound plus tolerance
A compact telemetry set; each row exists because a specific failure would otherwise go unnoticed

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.

Chapter 16

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.

BoundaryTrusted sourceNot trustedPractical rule
Authenticated identitysignature-verified credential claims or a server-side lookup of the API keya user id or tenant id supplied as a header or body fieldderive tenant and principal after verification, from the credential only
Client IPthe address of the connection from your own edge, or the forwarded address added by a proxy you control, at a known chain positionany X-Forwarded-For value the caller sentthe edge strips inbound forwarding and identity headers and sets its own; count trusted hops from the right
Tenant boundary in the storethe key prefix {t:<tenant>} built from the verified tenant idtenant ids that contain structural characterscharset-restricted tenant ids; the principal part is a keyed hash, so it cannot inject delimiters or tags
Policy sourcesigned, versioned bundle from the control planea policy file edited on a gatewaygateways accept only bundles with a valid checksum and a higher version
Control plane accessrole-based: author, approver, deployershared admin credentialsseparate roles; break-glass is logged and expires
Where identity and configuration come from

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.
Chapter 17

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.

IDInvariantHolds only whenWhere it is enforced, and what breaks it
I1One atomic authority cannot admit two contending requests against the same remaining budgetthe state and operation are valid and both requests reach the same authoritythe single script on the shard owning the key; says nothing about other regions or a second store
I2Multi-key enforcement is atomic only if all keys share a transaction boundaryevery key of a request carries the same hash tagthe multi-rule script and the control-plane validation; otherwise leases or compensation with bounded inconsistency, tested
I3Every decision is attributed to a policy version and a trusted principal scopeidentity comes from verified credentials, never from a header the caller controlspolicyVersion and deniedScope in the decision; the trust-boundary table
I4A 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
I5Retry-After is a hint, not a reservation—documented in the contract; jitter added; clients told to back off
I6A store timeout must not silently double-debit or double-refundcalls 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 issuedthe idempotency record in the multi-rule script; retention expiry reintroduces a bounded residual risk
I7Script 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
I8Regional 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
I9Limits do not replace authorization, anti-abuse, downstream protection or client discipline—the limiter composes with those systems and does not substitute for them
I10Every buffer, timeout, retry budget, key set, lease and fallback is finite and has overload behaviorlimits are configured and validateddeadline and pool bounds, TTL on every key, lease expiry, bounded fallback buckets and decision-record retention
I11Allow does not mean backend success—admission only; a downstream 429 or 503 can follow an allowed decision
I12Metrics are not an authoritative ledger—lossy telemetry for dashboards and alerts; contractual accounting needs its own path
I13Policy changes have explicit version and freshness semanticssnapshots carry a version, and gateways enforce a maximum age where revocation mattersmonotonic versions; no promise of instant revocation for a disconnected gateway
I14A privileged bypass is narrow, separately authenticated and audited—separate route with its own cap; an unmetered bypass is a flood channel
I15Every 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
The fifteen invariants and the conditions under which each holds

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.

Chapter 18

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.

#StepWhat happensState and numbers
1Edge, pre-authenticationThe 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 IPcount 7 of 20 in the current 10 s window; passes
2AuthenticationGateway A verifies key K1, resolves tenant Atlas and user U-17; a bad key would stop here with 401tenant atlas, principal U-17, route group read (GET /catalog)
3Authorization and policy lookupU-17 may call /catalog; gateway A evaluates bundle 42: user rule P-READ, tenant rule P-TENANT, leased global P-GLOBALthree applicable rules; policyVersion 42
4Local global leaseGateway A spends one of its leased P-GLOBAL tokens without calling the storechunk 100 → 99 remaining; no store call
5Key derivationPrincipal 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
6Atomic decision, request 1One 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
7Contention at T₀ + 8,000 msU-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 scriptA'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
8Response for BretryAfterMs 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 msexactly one of two contending requests admitted; no double spend
9RefillThe client retries after about one second with jitterdelta = 1,000 ms → floor(1,000 × 2,000 / 1,000) = 2,000 millitokens → request admitted, bucket 1,000
10Export request, lease firstU-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 recordlease granted; the tenant bucket is debited 10,000 millitokens if the rate rule allows
11Ambiguous timeoutA'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 idthe script finds the record for r-e41 and returns the stored result; the tenant bucket is debited once, not twice
12Fourth concurrent exportAnother 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
13Store loss during a later requestIf shard R were unreachable: P-READ falls back to the local bucket (capacity 5, 0.25/s); the export route fails closed with 503fallback: true in the decision; metrics record the degraded mode
Trace of a single Northstar request flow and its contention, retry and degradation variants

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.

Key takeaways
  1. 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.
  2. 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.
  3. 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.
  4. 04Atomic does not mean durable: failover, restore and eviction can make a bucket fuller, and the bound is that bucket's capacity.
  5. 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.
  6. 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.