Skip to content
SeeItFlow
System Design · 12 chapters · 35 incidents · ~84 min read

Distributed Rate Limiter Failures: Races, Outages & Overshoot

Thirty-five hypothetical failure scenarios for a fictional multi-tenant API, from boundary bursts and check-then-write races to failover loss, hot tenants, regional overshoot and stale policy. Each one is walked through as trigger, timeline, symptom, mechanism, detection, containment, recovery, prevention, the guarantee that still holds, and the tradeoff it costs. All numbers are teaching inputs, not measurements.

Mangesh MandlikLast updated: How lessons are made
Overview

The Failure Map: What Breaks, and Who Decides

Thirty-five failures of a distributed rate limiter, grouped by what actually goes wrong: counts that disagree, state that leaks, a store that fails over, a hot tenant, an identity that can't be trusted, a policy that is stale, a region that cannot be reached.

Every incident on this page is a hypothetical scenario built on the fictional Northstar API (tenants Atlas and Birch, key K1, user U-17, gateways A and B, Redis primary R with replica R2, regions EU and US). No incident describes a real outage at a real company, and every number is a teaching input or planning figure — not a benchmark, not a statistic. The design that these failures stress is the one from Complete Design: a token bucket per principal as the primary algorithm, one atomic store authority per key, a semaphore for concurrency, and weighted cost for expensive routes.

Each incident follows the same arc: what set it off, how it unfolded in time, what a caller or operator saw, the mechanism underneath, how you would detect it, what to do in the first minutes, how to recover, how to prevent it, what correctness still holds, and what the fix costs. A compact table closes every incident with nine of those elements (everything except the timeline) so they are easy to find; the prose around it carries the timeline and the arithmetic, and varies because the failures do.

Allowance failures versus telemetry failures

Two kinds of failure get confused constantly. An allowance failure means the limiter admitted or denied something the policy says it should not have: F01, F03, F10 and F21 are examples. A telemetry failure means the limiter decided correctly but the record of what it decided is wrong: F30 is the only pure case on this page. The distinction matters operationally because the responses are opposite. If you see an allowance failure, you change enforcement. If you see a telemetry failure, you must not touch enforcement; you repair the evidence. Treating a dashboard gap as an over-admission can lead to needless tightening, and treating a real over-admission as 'probably just metrics' lets it continue.

What 'availability' means when the limiter is strict

Fail-closed is easy to say and easy to misunderstand. When the store is unreachable and a route is strict, the gateway refuses requests it could have served. For the user that is unavailability of that feature, expressed as a 503 (service or dependency unavailable) rather than a 429 (you exceeded your quota); the two should not be interchangeable, because a client's correct reaction differs. The reason to accept it is that the alternative on an expensive route is an unmetered path to a backend that serves everyone. So availability, for Northstar, is defined per route: catalog reads keep working under a bounded local fallback, exports pause. Which route gets which mode is a product-and-risk decision recorded in policy, and F20 walks through it with numbers.

Failure map: all 35 incidents

IDIncident (hypothetical)CategoryWhat breaksAuthority involved
F01Fixed-window boundary double burstCounting & racesAllowance: 2×L inside a short intervalFixed-window counter (store or local)
F02Local counters on two gateways each allow the last slotCounting & racesAllowance: N × L fleet-widePer-process counters (no shared authority)
F03Naive GET/SET check-then-write raceCounting & racesAllowance: lost updates, over-admissionRedis primary R, multi-command client logic
F04INCR without TTL after a crashState lifecycle & memoryPermanent block or immortal keysRedis keys and TTL lifecycle
F05Sliding log memory growth on a hot keyState lifecycle & memoryMemory and latency of one shardSorted-set state on one shard
F06Token bucket clock moves backward or forwardCounting & racesAllowance: frozen or instantly refilled bucketsTime source (gateway clock vs. store clock)
F07Concurrent weighted reservations exceed the budgetCounting & racesAllowance: spent weight > budgetWeighted bucket, non-atomic check/debit
F08Multi-key partial debit when one rule deniesPolicy & rule precedenceFairness: denied callers are chargedRule chain across keys/shards
F09Cross-slot atomicity assumption breaks in Redis ClusterStore, cluster & failoverCapability: scripts rejected, fail mode takes overRedis Cluster hash slots
F10Primary failover loses acknowledged counter writesStore, cluster & failoverAllowance: one-shot leniency after promotionAsync replication R → R2
F11Store timeout: ambiguous write, double-check on retryClients & retriesAllowance: double debit or untracked admitGateway client + store script
F12The limiter overloads at peak; pool exhaustionOverload & hot keysLatency and availability of the whole APIGateway connection pools, shard CPU
F13One hot tenant saturates a single shardOverload & hot keysLatency of every tenant on the shardHash-tagged shard (tenant slot)
F14High-cardinality bogus principals exhaust memoryState lifecycle & memoryMemory; evictions or write errorsStore memory, key creation
F15Key eviction silently resets enforcementState lifecycle & memoryAllowance: depleted buckets read as fullStore eviction policy
F16NATed mobile users share one public IPFairness, identity & abuseFairness: legitimate users deniedAnonymous IP rule (P-ANON)
F17Untrusted forwarded-for header evades IP policyFairness, identity & abuseAllowance: IP limit bypassed; key explosionEdge/gateway identity extraction
F18Tenant/user limit conflict and wrong rule precedencePolicy & rule precedenceFairness and expectations; starvationPolicy semantics (all must allow)
F19Anonymous login attack bypasses user quotasFairness, identity & abuseAllowance for attacker: guesses per accountPre-auth IP rule, missing account key
F20Redis outage: strict fail-closed, bounded local fallbackStore, cluster & failoverAvailability vs. safety, per routeGateway fail mode, local buckets
F21Regional partition: independent regions overshoot global quotaMulti-region & leasesAllowance: up to n×GRegional enforcers (no coordination)
F22Regional lease exhaustion despite unused tokensMulti-region & leasesFairness/utilization: stranded quotaLease allocator, regional leases
F23Lost or duplicated lease grant after retriesMulti-region & leasesAllowance or waste ≈ one chunk per eventAllocator grant ledger, lease epoch
F24Stale policy after emergency tighteningControl plane & rolloutAllowance during propagation lagGateway policy cache, control plane
F25Rollback of bad policy and out-of-order snapshotsControl plane & rolloutMass denial or flip-flopPolicy versioning/distribution
F26Retry storm from synchronized Retry-AfterClients & retriesLoad on limiter/backend, not on admit countsClients, gateways, hot shard
F27Expensive export hits the concurrency cap at low RPSOverload & hot keysPerceived denial of a within-rate callerConcurrency semaphore/leases
F28Backend overload despite every client within quotaOverload & hot keysBackend capacity (aggregate mismatch)Aggregate rules, load shedding
F29Counter key collision contaminates tenantsPolicy & rule precedenceFairness/isolation across tenantsKey builder and namespace
F30Allowed telemetry lost or duplicatedObservability & bypassTelemetry only; allowance unaffectedEvent pipeline (not the authority)
F31Internal bypass or privileged route floods backendObservability & bypassBackend capacity; unmetered pathPrivileged route and exemption
F32Clock, time-zone and reset assumptions divergeMulti-region & leasesAllowance and expectations across regionsWindow definition, control plane
F33Persistence restore resets countersState lifecycle & memoryAllowance: rolled-back consumptionPersistence (RDB/AOF), ledger
F34Rolling deploy changes algorithm or policy versionControl plane & rolloutMixed enforcement, wrong-type errorsGateway versions, key schema
F35Malicious traffic consumes limiter state pre-authFairness, identity & abuseStore CPU/memory; neighbor latencyPre-auth tier, edge limiter
Map of the 35 scenarios. 'Authority' is the component whose state or decision the failure touches; allowance failures change what was admitted, telemetry failures only what was recorded.
F01, F02, F03, F06, F07

Counting and Race Failures

The first class of failure is arithmetic: the limiter counts correctly by its own rules, but those rules or the way they are executed don't match the promise made to the customer.

Fixed windows, process-local counters and multi-step Redis logic each leave a gap in which more traffic passes than intended. The remedy is mostly structural (one authority, one atomic step, one time source), which is why Architecture Deep Dive spends its time on the script rather than on tuning. The Redis scripting documentation describes a script as executing atomically, with other server activity blocked while it runs; that property is what closes F03 and F07, and it is also why scripts must stay short.

F01 — Fixed-window boundary double burst

Take P-ANON — 20 requests per 10 s, fixed windows aligned to epoch multiples of 10 s — and a scraper behind one Northstar-facing IP that knows it. At t = 9.9 s it sends 20 requests; the counter for window 1 reaches 20 and every request passes. At t = 10.1 s the window id flips, a fresh counter starts at 0, and another 20 pass. That is 40 admissions in 0.2 s, exactly 2×L, while each window individually obeyed the rule. Nothing is broken in the sense of a bug: the policy simply never promised anything about intervals that straddle a boundary.

Swipe to pan
Fixed-window boundary burstThe Fixed-Window Boundary BurstWindow 10 s aligned to epoch multiples · limit L = 20 per windowWindow 1 [0, 10): count 20 / 20 ✓Window 2 [10, 20): count 20 / 20 ✓20 req @ 9.9 s20 req @ 10.1 s0 s5 s10 s15 s20 s40 requests in 0.2 s = 2 × LEach window counter stays ≤ 20, so the limiter never objects.Around a boundary, a caller can use two windows' budgets back to back.
The same 20-per-10 s limit admits 40 requests in 0.2 s when the window id flips.

The damage comes from multiplication. If 500 coordinated IPs all do this, 500 × 40 = 20,000 requests arrive inside 0.2 s, an instantaneous rate of 100,000 req/s against a catalog backend that comfortably handles 2,000. Averaged over 20 s the same IPs look like 500 × 2/s = 1,000 req/s, which is why per-minute dashboards stay green. An attacker finds the boundary by probing: the first 429 marks the end of one window and the next admit marks the start of the next. A sliding-window counter estimates the second burst against a weighted share of the first and curbs it (still approximately), and a token bucket bounds the pair differently: it can admit up to 20 + 2T tokens in any T seconds, so 40 in 10 s but not 40 in 0.2 s. That is why Northstar keeps fixed windows only for coarse anonymous rules and uses buckets where burst shape matters.

FacetF01 at a glance
Trigger500 coordinated anonymous clients time their bursts to straddle the :09.9/:10.1 boundary.
Visible symptomOrigin sees ~20,000 requests in 0.2 s (≈100,000 req/s equivalent); p99 latency of Birch's calls jumps while the limiter reports zero violations.
Root causeThe counter is keyed by window index, so crossing the boundary resets the allowance; the policy bounds each window, not every interval.
DetectionBackend request rate sampled at 100 ms resolution vs. admitted-per-window; a 'burstiness' ratio (max 0.2 s admits ÷ per-window limit) approaching 2.
ContainmentTighten P-ANON temporarily or add a second, shorter window (e.g. 10 per 2 s) on the same key; shed at the edge.
RecoveryNothing to repair in state; the burst passes within seconds. Re-evaluate which routes can tolerate the 2× tail.
PreventionChoose a token bucket or sliding-window counter for burst-sensitive routes; keep fixed windows only where 2× at the edge is cheap.
Guarantee that remainsPer window: at most L admits. Across a boundary: up to 2L in a short interval, and no more.
TradeoffFixed windows cost one counter and one TTL per key; sliding approximations cost a little more state and are still approximate.
F01: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F02 — Two gateways each allow the last slot

Gateway A and gateway B each keep an in-process counter for the P-ANON rule on one IP. The fleet-wide intent is 20 per 10 s. At t = 4 s the IP has made 19 requests, and by an unlucky load-balancer spread, 10 of them went through A and 9 through B. A's local counter reads 10, B's reads 9 — neither is near 20. Both continue to admit, and both are correct about their own memory.

Formally, with N gateways each enforcing L locally the fleet admits up to N × L. With the 100 gateways of the P-GLOBAL example and a per-gateway limit of 20, that is 2,000 per 10 s for one IP, 100× the intent. Dividing the limit by N does not rescue it: 20 / 100 = 0.2 requests per gateway is not representable, and a load balancer that lands six requests in a row on one instance rejects a customer who is nowhere near the fleet limit. Sticky routing narrows the error but couples limit accuracy to LB behavior and breaks on every deploy or failover. The only fix that preserves the count is one shared authority per key — the move Complete Design makes in the Visual Design Journey scenes 8–10 — with a locally cached deny path to keep it cheap.

FacetF02 at a glance
TriggerA client's last permitted slot of a 20-per-10 s window is raced across gateways A and B with per-process counters.
Visible symptomThe caller observes 20 × N successful requests in 10 s depending on LB spread; or, with divided limits, premature 429s despite low usage.
Root causeState lives in each process, so no two instances share one view of the remaining allowance.
DetectionCompare sum of per-gateway admits per key with the declared limit; alarm when the ratio exceeds 1 over a window.
ContainmentPin the abusive identity to a deny rule at the edge or shrink per-instance limits in the emergency.
RecoveryMove the key to a shared authority (Redis primary R); deploy and let local counters retire.
PreventionTreat 'one key, one authority' as a design rule; local counters only as short-lived negative caches of authority answers.
Guarantee that remainsEach gateway's own counter is exact for its own traffic; the fleet total is bounded only by N × L.
TradeoffA shared store adds a round trip (low single-digit ms goal) and a dependency that now needs a fail mode.
F02: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F03 — Naive Redis GET/SET check-then-write race

Northstar's first shared-store version reads the counter with GET, compares in the gateway, and writes back with SET. It looks right in a single-threaded test. In production, A and B each execute GET rl:ip:203.0.113.9 within the same millisecond and both see 19; both decide 19 < 20, both SET 20. The store holds 20, and both requests have been allowed — one slot was spent, two requests used it.

The loss scales with concurrency and round-trip time. If a key sees 5,000 req/s and the read-compute-write gap is 1 ms, about five requests are in that gap at any instant, and each overlapping group can admit several requests while recording a single increment. A flood amplifies this: the busier the key, the more it under-counts. Atomic increments (INCR) solve plain counting; a token bucket needs read, refill arithmetic, comparison and write as one unit, which is what a Lua script or function provides — the Redis scripting docs describe a script as running atomically, blocking other activity on that instance while it runs. That atomicity is per instance; the durable story is F10.

FacetF03 at a glance
TriggerTwo or more gateways perform GET → compare → SET on the same key within one round trip.
Visible symptomAdmits exceed the limit under concurrency (the over-admission grows with traffic); the shared store looks consistent afterwards.
Root causeThe decision spans multiple commands that other clients can interleave with; the last SET overwrites rather than accumulates.
DetectionReplay test: N parallel clients vs. a limit of L; admitted > L means a race. Production: admitted/limit ratio per key under load.
ContainmentSwitch the hot rule to an atomic INCR/Lua path immediately; do not try to fix it with client-side locks.
RecoveryCounters self-correct on the next window; no data repair needed.
PreventionPut read-modify-write in one script per authority; add a concurrency test to CI that hammers a single key.
Guarantee that remainsWith the script: no two contending requests see the same remaining budget on one instance (I1).
TradeoffScript CPU is single-threaded work on the primary, so scripts must stay short and keys well spread.
F03: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F06 — Token bucket clock moves backward or forward

P-READ is a bucket with capacity 20 and refill 2/s, and the gateways supply now from their own clocks. Gateway A's clock is 30 s ahead. A's request stores updatedAt = t + 30. B, correctly timed, then runs the formula with now = t: max(0, now − updatedAt) = 0, so no refill arrives until B's clock catches the stored value. Thirty seconds at 2 tokens/s would have refilled 60 tokens, so U-17 stays at whatever she had — the bucket appears frozen and legitimate users see 429.

The opposite direction is the quieter one. A clock that jumps forward 60 s makes delta = 60, refill 120 tokens, clamped at 20: an exhausted bucket becomes full instantly, handing out an unplanned 20-request burst per key to every principal that touches that gateway. The cure is a single time source for the decision: read the store's clock inside the script (the script sees the same now for every gateway), persist an integer millitoken balance, and clamp so updatedAt never moves backward. Reading TIME inside a write script is allowed because of effects replication: the scripting docs say it became the default in Redis 5.0 and the only mode in 7.0, and that with it enabled non-deterministic commands such as TIME can be used freely. On a version older than 5.0 that is not the default, so check yours.

FacetF06 at a glance
TriggerA gateway or the promoted replica has clock drift of +30 s or a step of −N s while gateways supply now.
Visible symptomBackward: buckets freeze, valid users get 429 with a Retry-After that never gets true. Forward: depleted buckets refill instantly, bursts of 20 appear out of nowhere.
Root causeThe formula trusts a caller-provided timestamp; elapsed time is computed across two different clocks.
DetectionPer-gateway clock-offset metric; per-key 'refill rate observed vs. configured' outliers; sudden step in tokens between consecutive decisions.
ContainmentQuarantine the skewed gateway (drain it); restart NTP/chrony; clamp deltas to a sane maximum.
RecoveryDelete or let the affected buckets expire (idle TTL) and let them re-create full.
PreventionUse one time source inside the script, integer millitokens, and delta = max(0, now − updatedAt) with updatedAt = max(old, now).
Guarantee that remainsWithin one authority and a monotonic enough store clock, refill never exceeds rate × elapsed, so I4 holds.
TradeoffStore time ties admission to the store's health; a failover to a replica with a different clock still shifts it by the inter-host skew.
F06: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F07 — Concurrent weighted reservations exceed the budget

A weighted export costs 10 units under P-EXPORT. Birch's export bucket holds 15 tokens. Two exports arrive from different gateways at the same moment. With separate read and debit steps both read 15 ≥ 10, both proceed to debit, and the bucket either goes to −5 (if the debit is unconditional) or to 5 with 20 units spent (if the second write overwrites the first). The weight multiplies the damage of F03: a one-token race loses one request, a ten-token race loses ten.

Done atomically the sequence is deterministic. The first script sees 15, admits, stores 5. The second sees 5 against cost 10, deficit 5, and returns retryAfter = ceil(5 / 2) = 3 s. Two further traps belong here. First, a reservation made at admission is a prediction: an export that will actually scan a large range costs more than the nominal 10, so either charge a worst-case estimate and refund the difference after execution, or accept the estimate as a bounded error. Second, the refund itself is a write that can be duplicated by retries; it needs a stable request id, otherwise the same 'refund 4' applied twice mints tokens.

FacetF07 at a glance
TriggerTwo exports of cost 10 race against a 15-token bucket (P-EXPORT weighted cost).
Visible symptomOver-admission: 20 units are spent against 15 available; downstream sees two heavy jobs where one was allowed.
Root causeCheck and debit are separate, so both requests pass the check before either debit lands.
DetectionExport bucket going negative or total debited units per window above capacity + refill; admits-per-weight audit.
ContainmentPause the export route's admission, or temporarily lower its per-tenant concurrency cap (3 in-flight).
RecoveryLet the bucket refill; if it went negative, clamp at 0 with a one-off reset.
PreventionSingle atomic script: check cost ≤ tokens and debit in the same step; refunds keyed by request id.
Guarantee that remainsOn one authority, a request is admitted only if its full weight fits the remaining budget.
TradeoffCharging estimates up front is conservative for cheap runs; post-execution settlement is accurate but arrives after the damage is done.
F07: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
F04, F05, F14, F15, F33

State Lifecycle and Memory Failures

Limiter state is cheap per key and expensive in aggregate, and the interesting failures happen at the edges of its lifecycle: creation without expiry, growth without bound, deletion without notice, and restoration to the wrong moment.

The planning figure used throughout: 2M active keys × an assumed 200 B per key is 400 MB, about 800 MB per copy with 2× headroom, and about 1.6 GB for a primary plus one replica. The 200 B is an assumption to be replaced with a MEMORY USAGE measurement on real keys; the headroom is there for allocator overhead and replication buffers. The key model rl:v3:{t:atlas}:u:U-17:read has a TTL equal to time-to-full-refill plus slack, written in the same script as the update. Each incident below is what happens when one part of that sentence stops being true.

F04 — INCR without TTL after a crash or a bad key lifecycle

The fixed-window counter is created with INCR, and the EXPIRE is a second command issued by the gateway afterwards. At 02:14 a gateway process is killed between the two. The key exists with no expiry. If the key name embeds the window index the leak is a stray string; if an engineer 'simplified' the key to rl:v3:ip:203.0.113.9 the counter never resets, and from that moment that IP is permanently blocked after its twentieth request.

The slow version of the same bug is a leak. Suppose crashes or bad deploys strand 1,000 keys a minute: 1,000 × 1,440 = 1.44M keys a day, and at an assumed 200 B each (measure your own with MEMORY USAGE) that is 288 MB every day that never expires. The fix is structural: set the TTL in the same script as the write, so there is no instant at which the key exists without one. A cheap audit looks for keys whose remaining TTL is −1; I'd sample with SCAN, never KEYS, on a primary under load. The docs note that overwriting commands such as SET clear a key's timeout, so any code path that rewrites a counter must re-apply it.

FacetF04 at a glance
TriggerA gateway dies between INCR (key created) and EXPIRE (timeout set), or a rewrite path clears the TTL.
Visible symptomOne IP or user is blocked forever (reset never comes), or Redis memory climbs steadily with immortal limiter keys.
Root causeCreating the key and giving it a lifetime are two operations, and nothing guarantees both happen.
DetectionCount of limiter keys with TTL = −1 (sampled); memory growth without a matching rise in active users; 429s that never clear for a principal.
ContainmentDelete the offending key(s); run a sweeper that applies a default TTL to un-expiring rl:* keys.
RecoveryRe-run the sweep after deploy; verify the sampler now returns zero un-expiring keys.
PreventionSet value and TTL in one Lua call; set TTL = time-to-full-refill + slack; unit-test that every write path re-applies it.
Guarantee that remainsA correct bucket either exists with a TTL or does not exist; a missing key means 'full bucket', never 'blocked'.
TradeoffAn idle TTL that is too short refills a drained bucket early; too long keeps cold keys in memory.
F04: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F05 — Sliding log memory growth on a hot key

The sliding-log algorithm stores one entry per admitted request so it can count exactly. For Atlas at 1,000 requests/min that is up to 1,000 sorted-set members on the tenant key, and for 20,000 active users × 20 entries it is 400,000 members in total — workable. The incident starts with a design shortcut: the team also logs denied attempts 'for audit'. When Atlas floods at 75,000 attempts/s, the tenant log would take 75,000 × 60 = 4.5M entries inside a single minute, all on one key and therefore one shard.

At an assumed 50 B per sorted-set member (hypothetical; measure it), that single key is about 225 MB, and every trim, count or delete over it is O(log N + M) work inside one blocking command. Latency on every other key sharing that primary rises while the script walks the set. The fix is a design decision, not tuning: store only admitted requests (so the key is bounded by the limit itself, 1,000 here), or move to a counter-based approximation when exactness is not worth the memory. The sliding log keeps its place for low-limit, high-value rules where exactness is cheap and a flood cannot grow the set.

FacetF05 at a glance
TriggerDenied attempts are written into the sliding log as well as admitted ones during an Atlas flood.
Visible symptomBirch and others on the same shard see latency spikes; Redis memory grows by hundreds of MB from a single key.
Root causeMemory is O(requests recorded in the window), and the flood controls how many get recorded.
DetectionPer-key size (MEMORY USAGE, ZCARD) sampled for top keys; slow-script log entries on the trimming script.
ContainmentSwitch the rule to denial-without-write and delete or UNLINK the oversized key.
RecoveryLet the log drain through window expiry; verify shard latency is back to baseline.
PreventionRecord admits only; cap log length at the limit; use the counter approximation for large limits.
Guarantee that remainsWhile the log holds only admits, the count in the window is exact and ≤ limit.
TradeoffExactness costs memory proportional to the limit; the weighted counter trades exactness for constant state.
F05: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F14 — High-cardinality bogus principals exhaust memory

Plan the keyspace first. Two million active keys at an assumed 200 B each is 400 MB; with 2× headroom for allocator overhead and replication buffers that is about 800 MB per copy, so a primary plus one replica needs ≈ 1.6 GB. Now an attacker sends 50,000 requests/s, each with a never-seen principal id (random API key strings, or random user ids in a login form). If the limiter creates a bucket for every distinct principal, 50,000 new keys/s with a 60 s idle TTL means a steady state of 3M extra keys: 3M × 200 B = 600 MB, doubled to 1.2 GB — half as much again as the 800 MB planned per copy, reached in about a minute.

A longer TTL is worse. An idle TTL of one hour keeps 50,000 × 3,600 = 180M keys alive, 36 GB at the same assumed size. Whichever limit the memory hits (maxmemory, F15) the defender loses: evictions erase honest users' state, or writes begin failing. The defense is to refuse to allocate per-principal state for identities that have not earned it: verify the credential cheaply first (F35), bucket unauthenticated traffic by a bounded coarse key such as client prefix, set TTL to the refill horizon (for P-READ, 20 / 2 = 10 s plus slack), and alarm on key-creation rate, not just key count.

FacetF14 at a glance
Trigger50,000 req/s with fabricated principal ids against an endpoint that creates a bucket per distinct id.
Visible symptomMemory approaches maxmemory; evictions (F15) or write errors begin; legitimate users get slow or wrong decisions.
Root causeState allocation is driven by attacker-chosen cardinality; TTL is longer than the useful life of the key.
DetectionNew-key-per-second rate, used_memory slope, top-N principal cardinality per source prefix, ratio of one-request keys.
ContainmentSwitch affected routes to coarse (IP/prefix) buckets; drop per-principal creation for unverified identities; tighten TTL.
RecoveryLet TTLs drain (minutes at 60 s); confirm memory returns toward 400 MB baseline.
PreventionAuthenticate before per-principal state; TTL ≈ refill horizon; per-source cap on distinct keys; headroom planned for 2×.
Guarantee that remainsVerified principals keep their exact buckets; unverified traffic shares bounded coarse buckets.
TradeoffCoarse buckets are less fair (F16) and a short TTL gives idle users a fresh bucket sooner.
F14: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F15 — Key eviction silently resets enforcement

When the store reaches maxmemory, the configured policy decides what happens. With noeviction Redis returns errors for writes that would grow memory, which the limiter sees as store failures and routes through its fail mode. With an allkeys-* policy Redis instead silently deletes keys, and a deleted limiter key means a missing key — and a missing token bucket is treated as full. At 02:10, memory pressure under the F14 attack evicts the bucket for K1 while Atlas is flooding; the next request finds no key, creates a full bucket of 20 and admits 20 immediately.

Eviction looks harmless when the evicted bucket would have refilled anyway: after 10 s idle every P-READ bucket is full, so losing it changes nothing. It is harmful for recently depleted buckets, for fixed-window counters, and for any long-horizon quota whose accrued consumption exists only in this key. The volatile-* policies only consider keys with a TTL and, per the docs, behave like noeviction when no key has one, so mixing limiter keys with cache keys in one instance makes the policy hard to reason about. My preference: a dedicated limiter instance sized with headroom, TTL on every key, and an alert on any non-zero eviction counter rather than a policy that quietly self-heals into over-admission.

FacetF15 at a glance
TriggerMemory reaches maxmemory with an allkeys-* eviction policy while limiter keys share the instance.
Visible symptomPreviously throttled principals are suddenly admitted at full burst; no error is raised anywhere.
Root causeEviction deletes counter state; the algorithm's default for a missing key is 'full allowance'.
DetectionEvicted-keys counter from the store's INFO stats (non-zero is an alert); used_memory ≥ 85% of maxmemory; abrupt admit spikes for known-throttled keys.
ContainmentShed per-principal state creation (F14 controls), raise memory, isolate limiter keys onto their own instance.
RecoveryNothing to restore; accept that affected buckets reset once, then rebuild from fresh traffic.
PreventionDedicated instance, TTL on all keys, memory headroom, alert on any eviction; consider noeviction plus an explicit fail mode for write errors.
Guarantee that remainsKeys that exist behave exactly as configured; evicted keys behave as a fresh start, which is a bounded, one-time over-admission per key.
Tradeoffnoeviction converts memory pressure into visible errors (and fail-mode choices); evicting policies convert it into silent leniency.
F15: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F33 — Persistence restore resets counters unexpectedly

After a host failure, Redis R is restarted from its last RDB snapshot, taken every five minutes in this example. Snapshots are point-in-time copies; the docs are explicit that this style can lose the writes since the last save point, while AOF with an everysec policy bounds the loss to roughly a second. The restored keys reflect state from up to five minutes ago. Short-horizon limits barely notice: a P-READ bucket refills fully in 10 s, so a five-minute-old bucket plus elapsed time is nearly full either way.

Long-horizon quotas are where it hurts. Suppose Atlas also has a hypothetical daily plan quota of 100,000 requests, consumed at 1,000/min. A five-minute rollback forgets 5,000 requests, 5% of the day's quota, handed back for free; a restore from last night's backup forgets far more. Restoring is also where staleness stacks with F10: if the restored node is promoted, replicas may disagree with it. The practical rule is to match durability to the horizon. Rate buckets are disposable, and a restore is fine. Quotas that carry legal or billing meaning belong in a durable ledger (I12), with Redis as a fast admission cache over it.

FacetF33 at a glance
TriggerPrimary R restarts from a snapshot written five minutes earlier (or from an old backup).
Visible symptomShort-horizon throttles are barely affected; daily/monthly quota consumption silently rolls back, so tenants regain allowance they already spent.
Root causePersistence captures a past instant; state written after it is lost on restore.
DetectionRestart/restore event annotations; rdb_last_save_time age vs. quota granularity; reconciliation of cache-side consumption vs. ledger totals.
ContainmentReconcile affected tenants against the durable ledger and reapply consumed amounts before reopening quota.
RecoveryReplay admission counts from ledger/log since the snapshot where available; otherwise accept and document the credit.
PreventionAOF everysec or tighter for durable-ish counters; keep authoritative long-horizon quotas in a durable store; shorten snapshot interval.
Guarantee that remainsRate decisions keep their per-instance atomicity; the lost-state window is bounded by the persistence setting.
TradeoffTighter durability costs write latency and disk throughput on the limiter's hottest path.
F33: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
F09, F10, F20

Store, Cluster and Failover Failures

A store with atomic scripts gives you a correct decision on one instance. It does not give you correctness across a failover, across slots, or while it's down.

The Redis documentation is consistent on the limits. Replication is asynchronous by default, and WAIT reduces but does not eliminate the chance of losing an acknowledged write during failover; Sentinel's deployment notes say a Sentinel plus Redis system does not guarantee that acknowledged writes are retained. For Redis Cluster, multi-key operations require all keys to hash to the same slot, and scripts must receive every key they touch as declared key arguments; hash tags such as {t:atlas} force colocation. These sources were read in the official redis/docs repository on 6–7 Nov 2026 (see the ledger in the last chapter); the design below leans only on what they say.

PropertyWhat it givesWhat it does not give
Lua script / functionAtomic execution on one instance; one round trip for a multi-rule decisionDurability across failover; atomicity across slots or instances
Hash tag {t:atlas}Tenant's rules in one slot, so one script can check and debit them togetherSpreading a hot tenant; independence of a tenant from its shard's neighbors
Async replica R2Warm standby, read capacity, fast promotionZero-loss failover; the tail of unreplicated writes can vanish
WAITConfirms a number of replicas acknowledgedStrong consistency; it narrows the loss window only
noeviction / allkeys-*Visible errors vs. silent deletion of limiter keysA free choice; each changes which failure you see (F15)
What the store's primitives do and do not promise; each row corresponds to an incident in this chapter or the next.

F09 — Redis Cluster cross-slot atomicity assumption breaks

A hierarchical check for U-17 touches three keys: the user bucket, the Atlas tenant bucket, and a fleet-wide GET /catalog protection key. On the single-node staging Redis the three-key script works. In production the data lives on a cluster, where each key hashes to one of 16,384 slots and a multi-key operation requires every key to be in the same slot; the cluster spec says multi-key operations are only available when all keys hash to the same slot, and the scripting docs add that all keys a script touches must be passed as declared key arguments. The user and tenant keys carry the tag {t:atlas} and colocate; the global key has no tag and lives on another node. The script is rejected (in practice with a CROSSSLOT-style cross-slot error; the exact error text is not quoted in the sources checked for this page).

What the gateway does with that error decides the incident. Under fail-open, every request is 'a limiter error' and passes: a deploy that looked like a harmless refactor disables all enforcement. Under fail-closed, every request on that rule is denied: a self-inflicted outage. Neither is a rate-limiting decision; both are a missing capability being treated as an answer. The repair is to stop pretending: keep rules that must be atomic together under one hash tag (tenant-scoped rules), and evaluate the cross-tenant global rule as a separate, explicitly non-atomic step that may admit slightly more than the global limit or be handled by token leasing (F13). A staging environment with the same cluster topology would have caught this.

FacetF09 at a glance
TriggerA hierarchical script is deployed whose keys span two hash slots (global key lacks the tenant hash tag).
Visible symptomDecisions fail with errors on every request that touches the global rule; behavior then follows the fail mode (all admitted, or all denied).
Root causeAtomic multi-key scripts require all keys in one slot; the design assumed single-node semantics.
DetectionStore error-rate by error class (cross-slot), a step change in 'limiter error' decisions at deploy time, staging/production topology diff.
ContainmentRoll back the script; temporarily disable the cross-slot rule and apply the per-rule fail mode consciously.
RecoveryRe-deploy with colocated keys and a separate global step; replay a canary through the real cluster topology.
PreventionDesign key tags from the start ({t:atlas}); integration tests on a real cluster; fail on unexpected error class, never default-allow it.
Guarantee that remainsTenant-scoped rules (same slot) keep atomic all-or-nothing admission; the global rule is bounded-inexact by design.
TradeoffTenant tagging makes a hot tenant a hot slot (F13) and gives up automatic spreading.
F09: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F10 — Primary failover loses recently acknowledged counter writes

Redis replication is asynchronous by default: the primary acknowledges a write before the replica has necessarily received it. Primary R serves the shard that holds Atlas's keys; R2 trails by up to 200 ms during a busy moment. At t = 0 R dies. A failover promotes R2, whose state is whatever had been replicated. Every script effect in the unreplicated tail — the debits that clients were told succeeded — is gone. The Redis docs are plain about it: WAIT makes it harder to lose a write but does not turn Redis into a strongly consistent system, and acknowledged writes can still be lost during failover.

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.
Async replication leaves a tail of acknowledged writes that a promotion can discard.

Quantify the loss with the planning numbers. A shard averaging 25,000 script calls/s with a 200 ms tail has 25,000 × 0.2 = 5,000 effects missing. For a single key the bound is the debits it took in that tail: Atlas at its 1,000/min cap admits about 16.7/s, so a 1 s lag could lose roughly 17 tokens (~1.7% of the minute's quota); a user bucket regains at most the tokens it spent in the tail, ≤ 20. The loss is leniency, not strictness (a stale replica has more tokens than reality), it is silent, and it is a one-shot over-admission bounded by the unreplicated tail. A worse variant exists: Sentinel docs warn that a client partitioned with an old primary can keep writing to it until it learns of the failover, and those writes are discarded. For P-READ and P-ANON that is acceptable and cheaper than WAIT on every call. For quotas where it is not, use a durable ledger or an allocator that accepts the cost of synchronous replication.

FacetF10 at a glance
TriggerPrimary R crashes with 0.2 s of acknowledged-but-unreplicated writes and R2 is promoted.
Visible symptomAfter failover, some principals regain tokens they had spent; admits briefly exceed policy; no error is visible to anyone.
Root causeAsynchronous replication: acknowledgement does not imply the replica has the write; Lua atomicity is per instance, not durability.
DetectionReplication offset lag and master_repl_offset gap at failover time; failover events correlated with a short admit-rate bump.
ContainmentNone needed for most keys; for sensitive rules, temporarily force fail-closed during promotion, or WAIT on a narrow rule.
RecoveryLet buckets converge; tokens spent after promotion are accounted for correctly.
PreventionKeep replica lag small and observable; use WAIT only where the added latency is justified; separate durable ledgers from rate caches.
Guarantee that remainsOne instance never double-admits against the same budget (I1); across failover the excess is bounded by the unreplicated tail (I7).
TradeoffSynchronous replication reduces the tail but costs latency on every decision and still isn't strong consistency.
F10: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F20 — Redis outage: strict route fails closed, low-risk route falls back locally

At 14:02 the whole limiter store becomes unreachable for four minutes (power event on R's rack; R2 is also down). Every gateway's script call times out at its 5 ms budget. Northstar had chosen the fail mode per route in advance. POST /reports/export — ten units and an expensive backend — fails closed: the gateway returns 503 (not 429, because the caller did nothing wrong) with a small Retry-After hint. GET /catalog falls back to a per-gateway local bucket. P-GLOBAL is 40,000 req/s over 100 gateways, so each gateway's fair share is 400 req/s; with 100 gateways each enforcing exactly that, the worst case fleet-wide admission is 40,000 req/s again — the fallback preserves the global ceiling only if LB spread is even and gateways don't allow more than 1/100th.

Swipe to pan
Fail-open, fail-closed and bounded fallback by endpointPick the failure mode per endpoint, not per fleetDecision when the limiter store cannot answerEndpointRisk if wrongFail-openFail-closedBoundedExempt+capWhat “available” means/checkoutMoney and inventorywrites; abuse costly·✓··Available = answer safely:503 + Retry-After, neveran unmetered write/loginCredential-stuffingtarget·✓··503 on outage; brute-forcelimits are never skipped/catalogCheap read; backendhas headroom··✓·Keep serving: finite localcap per gateway, then local429s/reports/exportExpensive, about 10×a read·✓··503: a 10× call is notworth an unmetered gamblehealth probesMust answer duringincidents···✓Always answer: own small cap,separate authenticated route,audited429 Too Many RequestsThe caller exceeded its limit; slowing down helps.Retry-After is a hint, not a reservation.503 Service UnavailableThe limiter or a dependency cannot decide;it is the service’s problem, not the caller’s.
Fail-open, fail-closed and bounded local fallback chosen per route, not per limiter.

If an operator sets each fallback to 800 req/s 'to be safe for customers', the bound becomes 80,000 req/s, 2×. The honest framing of availability is per route: during the outage, catalog readers keep working with approximate limits, while exporters get a clear refusal. A strict-safety stance means some legitimate users are rejected: that is the intended behavior, because an unmetered export route is a way to topple the backend that serves everyone else. What users experience must be told as 503 with guidance, not a vague timeout, and the circuit breaker on the limiter client keeps the 5 ms budget from turning into 5 ms of queueing for every request.

FacetF20 at a glance
TriggerThe limiter store (R and R2) is unreachable for four minutes; gateway calls time out.
Visible symptomExports: 503 with Retry-After for all callers. Catalog: served under local per-gateway limits (≈400 req/s each). Dashboards show limiter errors, not throttling.
Root causeThe decision authority is gone; each route's configured fail mode decides between strictness and availability.
DetectionLimiter timeout/error rate, circuit-breaker state, fail-mode counter by route (open/closed/local), fallback admits vs. fleet ceiling.
ContainmentLet breakers hold; lower local fallback limits if backend saturates; keep high-risk routes closed.
RecoveryOn recovery, half-open breakers probe; local buckets are discarded; shared state resumes (and starts from whatever R holds, F10/F33).
PreventionDecide fail mode per route in policy; size fallbacks as ceiling/N; rehearse with game days; multi-zone store.
Guarantee that remainsExports: no admission without the authority. Catalog: admission ≤ N × local limit (40,000 req/s at 400 × 100).
TradeoffFail-closed trades availability of a feature for backend safety; local fallback trades exactness for continuity.
F20: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
F12, F13, F27, F28

Overload and Hot-Key Failures

Capacity failures come in two shapes: the limiter itself becomes the bottleneck, or the limiter is fine and the thing behind it is not.

The limiter's load is made of attempts. At the 50,000 req/s baseline a ten-times surge is 500,000 admission calls/s; with a hypothetical 40,000 script executions/s budget per primary, that is 500,000 / 40,000 = 12.5, so at least 13 shards, and Northstar plans 20 (25,000/s each, 62.5% of budget). That assumes uniform hashing, an assumption that hot tenants break (F13). Five hierarchical checks in five stores would be 2.5M interactions/s; the same rules in one same-shard script are 500,000 script calls. The patterns load shedding, bulkhead and circuit breaker apply directly here, and cascading failure describes the way an overloaded limiter propagates into the API.

F12 — The limiter itself overloads at peak; pool exhaustion

Peak is 500,000 admission calls/s across 20 shards: 25,000/s per shard, 62.5% of a hypothetical 40,000/s budget. Now a shard pauses for 50 ms (a rewrite, a big-key delete). Each gateway handles 500,000 / 100 = 5,000 req/s, and by Little's law concurrent in-flight store calls ≈ rate × latency: at 2 ms that is 10; at 50 ms it is 250. A pool of 64 connections is saturated once latency exceeds 64 / 5,000 = 12.8 ms. Callers queue for a connection, the queue grows, and requests wait long enough that the client times out and retries, adding load.

So the limiter, whose job is to protect the backend, has become the thing every request waits on. Three bounds fix this and all three must be finite (I10): a small pool with a short acquire timeout; a total deadline (say 5 ms) after which the fail mode applies; and a breaker that stops calling a struggling shard so it can recover. Latency from one shard must not queue behind requests for healthy shards, so pools are per shard (a bulkhead). If that shard's pool is empty the right answer is the route's fail mode immediately, not waiting.

FacetF12 at a glance
TriggerA 50 ms stall on one shard at the 500,000 req/s peak; store latency rises from ~1 ms to 50 ms.
Visible symptomGateways accumulate waiting requests; p99 admission and the API p99 balloon; clients time out and retry; eventually 5xx or fail-mode behavior fleet-wide.
Root causeUnbounded queueing for connections (in-flight = rate × latency exceeds pool); no per-shard isolation.
DetectionPool wait time and pool-saturation gauge, per-shard latency histograms, breaker opens, slowlog entries, in-flight count by shard.
ContainmentOpen the breaker for the slow shard and apply fail mode; shed lowest-priority routes; reduce retries.
RecoveryHalf-open probes; restore pool sizing; investigate the pause source.
PreventionPer-shard pools with acquire timeouts, total deadline, breaker, concurrency limit on in-flight limiter calls, headroom at 62.5% rather than 95%.
Guarantee that remainsDecisions that complete remain atomic; requests that don't complete follow the explicit fail mode.
TradeoffShort deadlines convert latency into fail-mode decisions, i.e. into more denials or less accurate admits.
F12: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F13 — One hot tenant saturates a single shard

Atlas floods at 75,000 attempts/s, 15% of the 500,000 req/s peak, and all of its keys carry the tag {t:atlas} so one hierarchical script can touch them. Everything lands on one shard whose budget is 40,000 script executions/s. The shard sees 75,000 / 40,000 = 187.5% of its capacity from Atlas alone; add the ordinary 25,000/s average that shard would serve anyway and the load is 100,000 / 40,000 = 250%. Birch lives on the same slot range by hash luck and now gets slow decisions, though Birch has done nothing.

Swipe to pan
Hot key and shard skewUniform on average, hot on one shardLimiter load is attempts, not admits (hypothetical planning inputs)budget 40,000/s75,000avg 25,000/s = 62.5%shard 1Atlasshard 20attempts per second per primary, 20 shards, uniform hashing assumed1 · Negative cache (denial storms)Gateway denies locally for the store’s retryAfter(floor ≈ 250 ms), so repeat attempts never reachthe shard.2 · Leased chunks (admit-heavy keys)P-GLOBAL: 40,000 req/s across 100 gatewaysChunk = 100 tokens → store ops 40,000/s → 400/sParked: 100 × 100 = 10,000 tokens= 0.25 s of global capacity: the staleness andovershoot bound of leasing. Tokens parked in onegateway are unavailable to the others.Sizing arithmetic500,000 admission calls/s ÷ 40,000 per primary = 12.5, so at least 13 shards; plan 20 → 25,000/s = 62.5% of budget.The Atlas flood adds 75,000 attempts/s on one key set: one shard sees 75,000 > 40,000, whatever the average says.
A hot tenant's attempts, not its admits, saturate one shard; caching and leasing flatten it.

The important detail is that the limiter's load is attempts, not admits. Atlas's actual admits are capped at 1,000/min (≈16.7/s); the other ≈74,983/s are denials, and each denial still costs a script run. Two different mitigations apply to two different shapes. For deny-heavy keys, the gateway honours the store's retryAfter with a short negative cache (a floor of 250 ms): once a gateway hears 'denied, retry in 0.8 s' it denies locally for that long. 100 gateways making at most 4 calls/s each means 400 store calls/s instead of 75,000, a 99.5% reduction; staleness costs at most 250 ms × 16.7 tokens/s ≈ 4 tokens of Atlas's tenant budget, about 0.4% of its minute. For admit-heavy keys (P-GLOBAL at 40,000/s) the answer is token leasing: chunks of 100 tokens across 100 gateways cut store operations from 40,000/s to 400/s, with up to 100 × 100 = 10,000 tokens parked in gateways, 0.25 s of global capacity, as the explicit staleness bound.

FacetF13 at a glance
TriggerAtlas floods at 75,000 attempts/s on keys that colocate on a single shard (40,000/s budget).
Visible symptomThe shard saturates (250% with its normal load); decisions for every tenant on it slow; Birch sees latency though under limit.
Root causeHash-tagged tenant keys concentrate a tenant's load on one slot; every attempt, even denied, costs a script run.
DetectionPer-shard CPU/script rate skew, top-key by calls/s, denial ratio by tenant, latency by shard vs. fleet median.
ContainmentEnable the denial negative cache; shift Birch's slot range if possible; edge-block the flood source.
RecoveryShard load drops as caches engage; rebalance slots later; release caches by TTL.
PreventionNegative cache for deny-heavy keys; leased chunks for admit-heavy keys; per-tenant sub-keys where atomicity allows; shard-skew alerts.
Guarantee that remainsAdmitted traffic for Atlas stays at its limit; leased/cached paths add bounded error (≈4 tokens of negative-cache staleness; 10,000 leased tokens = 0.25 s).
TradeoffLocality and staleness are the price for not melting a single shard.
F13: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F27 — Expensive export hits the concurrency cap despite low RPS

Atlas runs a nightly job that starts one export every 10 s, 0.1 req/s — nowhere near any rate rule. Each export runs for 90 s. By Little's law the in-flight count is arrival rate × duration = 0.1 × 90 = 9, but P-EXPORT allows only 3 in flight per tenant. The sustained ceiling is 3 / 90 = 0.033/s, i.e. 2 exports per minute, against 6 arriving per minute: four of every six exports (two in three) are refused, and the 9 in-flight the job wanted can never materialize. The job's owner reads 'rate limit exceeded', checks the 1,000/min policy and is baffled: the rate was fine; the semaphore was not.

Concurrency and rate are different mechanisms with different failure modes. A rate bucket needs only elapsed time; a semaphore needs acquire and release, and release can fail. If a worker crashes holding one of Atlas's three permits, it stays held. The lease must therefore expire (TTL longer than the longest legitimate export, with a renewing heartbeat); but too short a TTL lets a slow job lose its lease and a fourth run start. The response should say which limit refused the request, with a Retry-After that is only a hint (I5), and releases must be idempotent so a retry doesn't free someone else's permit.

FacetF27 at a glance
TriggerAtlas starts 0.1 exports/s, each lasting 90 s, against a 3-in-flight cap.
Visible symptomSteady-state 429s on exports with a healthy request rate; the client logs say 'limited' and the rate dashboard says 'fine'.
Root causeLittle's law: in-flight = arrival rate × duration = 9 > 3; the semaphore, not the rate rule, rejects.
DetectionDenial reason concurrency by tenant; in-flight gauge at the cap; permit age distribution and leaked-permit count.
ContainmentRaise the cap for the tenant if backend headroom allows; ask the caller to pace to 2 exports/min.
RecoveryExpire leaked permits by TTL; verify holders match running jobs.
PreventionReturn the refusing rule in the response; permits with TTL + heartbeat; release by lease id; document concurrency vs. rate.
Guarantee that remainsAt most 3 live permits per tenant under one authority; a crashed holder's permit lives at most the TTL.
TradeoffLonger TTL tolerates slow jobs but blocks capacity after a crash; shorter TTL recovers faster but risks double-running.
F27: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F28 — Backend overload persists although every client is within quota

Northstar's catalog backend comfortably serves 2,000 req/s per cell (a cell is one independently scaled copy of the backend with its own capacity). Each user is within P-READ's 2/s sustained refill, but 1,500 users all at their ceiling produce 1,500 × 2 = 3,000 req/s, 150% of that cell, with no individual violating anything. P-GLOBAL's 40,000/s protects the fleet because it is sized for 20 such cells (20 × 2,000); it says nothing about whether one cell's traffic is unevenly distributed to a single backend.

Per-client limits bound each client, not the sum. The sum is determined by how many clients show up, and a launch, a mobile push or a cache expiry can bring thousands at once. The response is a layered one rather than a larger counter: an aggregate admission cap near the backend (sized to measured capacity), load shedding that prefers cheap reads over exports when the backend's latency climbs, and bulkheads so a surge on one route doesn't consume capacity for another. When shedding, deny the least valuable work first and be explicit that those are 503s from overload protection, not 429s attributed to a customer.

FacetF28 at a glance
Trigger1,500 compliant users at 2 req/s each hit one 2,000 req/s backend cell.
Visible symptomLatency and 5xx at the backend rise; nobody is denied by per-client limits; Birch and Atlas both degrade.
Root causeThe limits bound each caller but nothing bounds the aggregate against backend capacity.
DetectionBackend saturation (latency, queue depth) while limiter deny rate stays flat; sum of admitted rate vs. measured capacity.
ContainmentTurn on load shedding / an aggregate cap; lower low-priority routes; scale the cell.
RecoveryReturn to steady state as the surge drains; retune capacity numbers from load tests.
PreventionAggregate rules (P-GLOBAL, per-cell), shedding priorities, bulkheads, autoscale, capacity tests.
Guarantee that remainsPer-caller allowances hold; the aggregate stays within the configured aggregate cap.
TradeoffAggregate caps deny compliant users: fairness shifts from 'within your quota' to 'the system is full'.
F28: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
F16, F17, F19, F35

Fairness, Identity and Abuse Failures

A limit is only as fair as the key it counts, and a key is only as trustworthy as the process that derived it.

The same limiter code can be too strict (people sharing an address), too weak (an attacker minting addresses) or irrelevant (an attacker aiming at an account rather than a source). The identity question precedes the counting question: the invariant I3 requires each decision to be attributed to a trusted principal scope, not to whatever string the request supplied. The ordering rule used here is cheap edge limits keyed by network source first, verification of the credential second, and principal-scoped limits third, so unauthenticated callers never create expensive state and never learn another tenant's quota.

F16 — NATed mobile users unfairly share one public IP

A mobile carrier places 500 handsets behind a single public address. P-ANON counts 20 requests per 10 s for that IP. When a marketing push opens the Northstar app on all of them at once, the first 20 requests succeed and the remaining 480 of 500 (96%) are denied. Each user, individually, made one request. The limiter's view — one noisy source — is the NAT's doing, not theirs.

The inverse is the abuser who hides behind the same sharing, which is why simply raising the IP limit is a poor reflex: 500 × 20 = 10,000 requests per 10 s through that address is enough to hurt. A better structure is two-tiered. Unauthenticated routes (a first GET /catalog) keep a generous, coarse IP rule sized for known carrier-grade NATs. As soon as a request carries a verified identity (a signed token, an API key tied to a tenant), the limit moves to that principal, where each handset has its own P-READ bucket. Device tokens issued after a first challenge can narrow anonymous keys a step further. Whichever signal is used, the denial response should say that the limit applies to the network address, so support can recognize the pattern.

FacetF16 at a glance
Trigger500 handsets behind a carrier NAT open the app within the same 10 s window.
Visible symptom480 of 500 first-time requests get 429; support tickets cluster by carrier; abuse dashboards show 'one hot IP'.
Root causeThe key (IP) conflates many people into one principal.
DetectionMany distinct user agents/devices with low individual rates under one IP; per-IP deny rate with high distinct-client count; support reports.
ContainmentAllow-list or raise the cap for known carrier-NAT ranges on read-only routes; apply per-device throttles.
RecoveryReset affected IP keys; communicate to affected carrier users.
PreventionAuthenticate early and key by principal; tier anonymous limits; use device/session tokens; carrier-aware ranges.
Guarantee that remainsEvery key stays enforced exactly; the weakness is which principal the key represents.
TradeoffMore granular identity means more state (F14) and more trust questions (F17).
F16: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F17 — Untrusted forwarded-for header evades IP policy

Northstar's gateways derive the client IP from the leftmost entry of X-Forwarded-For. That header is plain text and a client can send it. An attacker running a single machine at 5,000 req/s changes the header on every request: 5,000 different 'IPs' per second, each with a fresh P-ANON window of 20. Every request is admitted, and the limiter has created 5,000 new keys per second to remember them (the F14 shape).

The fix is about trust, not parsing. Only append-style proxy headers set by infrastructure you control can be believed, and the correct entry is the one added by your own outermost proxy. The standard approach is to take the address of the connection from the last trusted hop (the edge load balancer sets or overwrites the header; gateways discard any value coming from outside it, and count hops from the right). I3 also forbids attributing decisions to an unverified header, so the decision log must record which hop supplied the address. Where the edge is a CDN, use its connecting-IP field rather than the generic header. IPv6 adds another detail: one customer can control a /64 or more, so keys should aggregate to a prefix rather than treat each address as a person.

FacetF17 at a glance
TriggerAn attacker rotates X-Forwarded-For per request against an anonymous endpoint.
Visible symptomNo 429s for the attacker; backend sees the full 5,000 req/s; key-creation rate soars.
Root causeThe IP key was taken from an attacker-controlled header.
DetectionHigh cardinality of source 'IPs' with identical TLS fingerprints/connection peers; mismatch between socket peer and header value.
ContainmentIgnore client-supplied forwarded headers at the edge; key on the socket peer or the trusted hop.
RecoveryPurge junk keys by TTL; re-baseline per-IP denial rates.
PreventionDefine the trusted proxy chain in config; strip/overwrite at the edge; aggregate IPv6 to a prefix.
Guarantee that remainsLimits are exact for the identity the system actually trusts; they say nothing about an identity it doesn't.
TradeoffStrict peer-based keys can punish users behind unknown intermediaries.
F17: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F19 — Anonymous login attack bypasses authenticated-user quotas

POST /login happens before anyone is authenticated, so none of the per-user quotas exist yet. A botnet of 2,000 IPs each sends 2 attempts/s, inside P-ANON's 20 per 10 s, aimed at the one account U-17 and at a long list of other usernames. That is 4,000 guesses per second, 345.6 million per day (4,000 × 86,400). Each IP is entirely polite; the target is not.

Another key is needed: the targeted account, hashed so the raw username isn't stored in key names. A bucket of, for example, 10 failed attempts per 10 minutes per account bounds guesses at U-17 to 1/min = 1,440 per day instead of the botnet's 345.6M. But this key can itself be weaponised. An attacker who can send failures for U-17 would lock the real owner out; so the account key throttles (slows, requires a challenge, sends the owner a notice) instead of hard-locking, and successful logins from known devices bypass the failure count. The limiter provides the counting; credential-stuffing detection, WAF challenge and breached-password checks are separate controls (I9).

FacetF19 at a glance
Trigger2,000 IPs, each under P-ANON, spread 4,000 login attempts/s over many accounts including U-17.
Visible symptomCredential-stuffing succeeds for some accounts; login p99 rises; per-IP deny rate stays near zero.
Root causeThe only pre-auth key is the source IP; the attack's shape is per-target, not per-source.
DetectionFailed-login rate per account and per ASN; distinct IPs per account; login success ratio drops.
ContainmentAdd a per-account failure bucket and challenge; block the botnet's ASN ranges at the edge.
RecoveryForce resets for compromised accounts; review successful logins in the window.
PreventionDual keys (IP and hashed account); progressive delay not lockout; breached-password checks; device recognition.
Guarantee that remainsEach key's limit is enforced exactly; per-IP polite is still per-IP polite.
TradeoffPer-account throttles can be abused to degrade a victim's login unless designed as slow-down and challenge.
F19: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F35 — Malicious traffic consumes limiter state before authentication

Northstar decides to rate limit by API key, so the limiter runs first and the key is whatever the request header says. A scanner floods 100,000 req/s with random strings in the API-key header. Every request creates a bucket, 100,000 new keys/s. With a 60 s idle TTL that is 6M live keys × 200 B = 1.2 GB (the whole planned fleet footprint for two million keys is 400 MB), and the script executions alone are 100,000 / 800,000 = 12.5% of the 20-shard budget before a single real request is served. Birch, calling with a valid key, now competes with this noise for the same shards.

The ordering of checks is the defense. A cheap local signature or key-format validation can reject syntactically bad credentials at the gateway without touching the store. Before that, a coarse in-process limiter on the source address (no network call, bounded memory, approximate by design) absorbs volume. Only after a request is attributable should per-principal state be created. That order doesn't leak quota information either: unauthenticated callers receive the same generic denial and no remaining-quota headers, and a 401 for a bad key is not a 429. The cost is that a very large distributed flood still lands on the CPUs of the gateways; the limiter is not a DDoS service (I9).

FacetF35 at a glance
Trigger100,000 req/s with random API-key strings reach the limiter before key verification.
Visible symptomStore CPU and memory climb; real tenants see higher latency; denial rates look odd because most 'principals' have one request.
Root causePer-principal state is created for unauthenticated, attacker-chosen identities.
DetectionRatio of keys with one request; failed-auth rate; store key-creation rate; share of limiter load from invalid credentials.
ContainmentEnable a pre-auth coarse limiter at the edge; reject malformed credentials locally.
RecoveryTTL drains the keys; verify store memory returns to baseline.
PreventionOrder: edge/IP coarse → verify credential → principal-scoped limit; local validation before network calls.
Guarantee that remainsVerified principals keep their exact quotas; unverified traffic gets only coarse, bounded state.
TradeoffThe pre-auth tier is approximate and in-process, so volumetric abuse is only damped, not eliminated.
F35: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
F08, F18, F29

Policy and Rule Precedence Failures

Hierarchies of limits are where careful designs break: which rule denied, which rules were charged, and whose key was it anyway.

Northstar's semantics are simple on paper. A request must pass every applicable rule — user bucket, tenant quota, route cost, concurrency cap, fleet protection — and the effective limit is the most restrictive. Three things go wrong in practice: partial charges when one rule denies (F08), precedence that doesn't match the operator's mental model (F18), and keys that don't isolate what they claim to (F29). All three are specification failures, not Redis failures.

F08 — Multi-key partial debit when one rule denies

A request must pass two rules: U-17's P-READ bucket and the Atlas tenant quota of 1,000/min. The implementation is sequential: debit the user's bucket, then check the tenant's. The tenant is at its cap, so the request is denied, but U-17's token is already gone. The denied user is charged for service they never received.

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.
Same-slot rules can be checked and debited together; cross-slot rules cannot.

With the tenant exhausted for 30 s and a client retrying at 5 req/s, U-17 spends five tokens a second and refills two: net loss 3 tokens/s, so a full 20-token bucket empties in 20 / 3 ≈ 6.7 s. When the tenant window resets she is still locked out by her own empty bucket — a failure that the tenant's denial caused but that outlasts it. Two correct designs exist. If the keys share a hash slot (tag {t:atlas}), one script checks every rule first and debits only if all pass: all-or-nothing, I2's condition. If the rules sit on different shards, no single transaction exists; you must either refund the first debit when the second rule denies (with idempotency, or the refund can duplicate), or reorder to check the scarcest rule first and accept a bounded inconsistency.

FacetF08 at a glance
TriggerAtlas's tenant rule is exhausted while U-17's user bucket is still full; the user rule is debited first.
Visible symptomU-17 gets 429 from the tenant rule and later from her own bucket although few requests were served.
Root causeSequential debit-then-check without compensation across two rules.
DetectionPer-user token drain rate above the served-request rate; denials attributed to the user rule right after tenant denials.
ContainmentReorder to check before debit; refund user tokens for tenant denials during the incident.
RecoveryReset affected user buckets; verify no negative balances.
PreventionOne same-slot script: check all rules, then debit all. If sharded, reservation with idempotent compensation.
Guarantee that remainsIn a same-slot script, a request that any rule denies consumes nothing (I2).
TradeoffColocating rules limits spreading; cross-shard rules need compensation and accept brief inconsistency.
F08: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F18 — Tenant/user limit conflict and wrong rule precedence

Atlas asks for a higher per-key limit for K1, and an operator adds an override of 100 reads/s. Atlas's tenant plan is 1,000/min = 16.7/s. The system's semantics are 'every applicable limit must permit the request', so the override only raises one link of the chain: the tenant's sustained ceiling stays 16.7/s (and a user-level bucket such as P-READ still refills at 2/s). Because the tenant budget is a minute-sized allowance, though, K1 can spend it as a burst: at 100/s for 10 s it consumes the whole 1,000-per-minute budget, and every other Atlas user is denied until the window ends.

The incident has two readings. In the first, the semantics are right and the expectation is wrong: nobody showed the operator the effective limit. In the second, an implementation uses 'most specific rule wins' without a tenant cap: K1 then runs at 100/s = 6,000/min, six times the tenant plan (6,000 / 1,000 = 6), starving others. The design answer is to make precedence explicit (all must pass), compute and display the effective limit when a rule is authored, lint overrides that exceed their parent, and name the denying rule in the response so the customer sees 'tenant quota' rather than 'your key'.

FacetF18 at a glance
TriggerAn operator adds a K1 override of 100 reads/s under a tenant quota of 1,000/min.
Visible symptomK1 consumes the tenant budget in 10 s; other Atlas users are denied; the customer reports 'our override doesn't work' or 'one key starves the rest'.
Root causeAmbiguous precedence plus no view of the effective limit along the rule chain.
DetectionDenials by rule id and scope; effective-limit computation per principal; override-exceeds-parent lint results.
ContainmentCap the override or lift the tenant quota deliberately; communicate which rule denies.
RecoveryPublish a corrected policy version; clear affected counters if needed.
PreventionAll-applicable-must-allow semantics, policy linting, effective-limit preview, reasons in responses.
Guarantee that remainsNo request is admitted unless every applicable rule permits it.
TradeoffRevealing the denying rule helps legitimate callers and leaks structure to others (authenticated callers only).
F18: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F29 — Counter key collision contaminates tenants

A refactor drops the tenant from the user key: rl:v3:u:U-17:read. Atlas's U-17 and Birch's U-17 — two unrelated people whose ids both came from small per-tenant sequences — now share one bucket of 20. During an Atlas flood on that user id, Birch's U-17 receives 429 on a first request. Another variant, concatenating fields without a delimiter, makes tenant 'ab' + user 'c1' the same string as tenant 'a' + user 'bc1'.

Contamination is silent: every individual decision is internally consistent, and both parties are told 'rate limited' by an honest-looking system. The fix is a key builder used everywhere: tenant first, mandatory, inside the hash tag; fields length-prefixed or encoded; principals hashed for privacy (also keeping raw ids out of logs); version and namespace as fixed prefixes. A unit test creates identical principal ids in two tenants and asserts distinct keys, and a canary request from Birch with Atlas's flood running checks isolation end to end. I3 asks the same of every decision log: the scope that produced the decision must be recorded.

FacetF29 at a glance
TriggerA key-format refactor omits the tenant or joins fields ambiguously.
Visible symptomBirch's U-17 is throttled by Atlas's U-17's traffic; support sees 429s on near-idle accounts.
Root causeTwo principals map to one key; the limiter has no way to know.
DetectionCross-tenant canary: the same id under two tenants; keys with multiple distinct tenant labels in logs; unexplained denials at low usage.
ContainmentRoll back the key builder; version the namespace; clear the polluted keys.
RecoveryRe-issue buckets for affected principals; message Birch.
PreventionOne audited key-builder; length-prefix/escape; unit tests for collisions; hash principals; tenant in the hash tag.
Guarantee that remainsCorrectly namespaced keys keep tenants isolated.
TradeoffHashing and tagging reduce debuggability unless decision logs carry the plain scope.
F29: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
F21, F22, F23, F32

Multi-Region and Lease Failures

Across regions, no design is both strictly exact and fully available during a partition (I8). The honest choice is which bound to advertise and which cost to pay.

For Atlas's global quota G = 1,000 requests/min, three designs sit on one axis. Independent regional enforcement is fully available and unbounded in overshoot (up to n × G). A home-region authority has zero overshoot but makes the remote region pay a WAN round trip (80–120 ms in this illustration) and deny during a partition. Regional leases sit between them with a stated bound.

DesignWorst-case admitted in the window (G = 1,000, 2 regions)Availability during partitionCost
Independent full limit per region2,000 (2×G)FullAdvertised global limit is not kept
Home-region authority1,000 (zero overshoot)Remote region denies80–120 ms WAN RTT on remote admits (illustrative)
Leases, clamped to the window, no fallback1,000 (zero overshoot)Each region admits only what it holds; up to 200 tokens strandedRenewal traffic; lease sizing
Leases with unclamped carry-over (≤100 per region)≤ 1,200As above, slightly longerBounded +200 (+20%)
Leases + carry-over + local fallback (100 per region)≤ 1,400Keeps serving after lease expiryBounded +400 (+40%) in total
Bounds for the Atlas example. Lost grant records from an asynchronous allocator failover add chunk × grants lost (F23).

F21 — Regional partition: independent regions overshoot the global quota

Atlas's global P-TENANT quota is G = 1,000 requests/min across regions EU and US. During a network partition between the regions (or between each region and the allocator) a design that enforces the full limit independently in each region admits up to 1,000 in EU and up to 1,000 in US: 2,000/min = 2× G. With n regions the worst case is n×G. The naive design isn't wrong to be available during a partition; it is wrong to advertise a global limit it can't keep.

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.
The overshoot bound is a sum of named, finite terms, not an unknown.

A leased design gives each region only a share to spend. The allocator holds the pool for the minute; regions ask for chunks of 100 tokens with a 15 s TTL, clamped to the window end; grants never exceed the remaining pool. If the US holds 200 unspent tokens when the partition starts, it can admit at most those 200 until they expire and gets nothing new: overshoot is 0, the cost is up to 200 tokens (20% of G) stranded and requests denied. The general bound for admitted traffic in a window is G + carry-over of unclamped leases across the boundary + local fallback allowances + grant records lost in an allocator failover. With two regions, carry-over ≤ 100 each gives +200 (+20%), and a bounded fallback of 100 each adds another +200, so the worst case is 1,400 = 1.4 × G if both are permitted. A strict home-region variant yields zero overshoot but forces the remote region to pay a WAN round trip (80–120 ms in this illustration) and to deny during partition: there's no zero-overshoot, fully-available option (I8).

FacetF21 at a glance
TriggerA partition separates EU, US and the allocator while Atlas keeps sending traffic in both.
Visible symptomIndependent design: admits climb to ~2× G; leased design: US exhausts its parked tokens and begins denying while the global quota looks unused.
Root causeWithout a coordinator's current view, local admission is either unbounded (independent) or bounded by what was leased.
DetectionAllocator-side: grants vs. pool; region-side: lease remaining, time since last renewal; cross-region admits per window vs. G.
ContainmentDecide per tenant: hold at the lease bound (strict) or allow bounded fallback; page the network owner.
RecoveryOn heal, regions reconcile unspent leases and the allocator reconciles grants.
PreventionClamp leases to the window; cap fallback; document the advertised bound; test partitions.
Guarantee that remainsWith clamped leases and no fallback: zero overshoot. With carry-over and fallback: ≤ 1,400 (1.4×) in the stated example.
TradeoffEvery unit of availability in a partition is paid for with a unit of possible overshoot or stranded quota.
F21: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F22 — Regional lease exhaustion despite globally unused tokens

Atlas's pool of 1,000/min is split statically 50/50. EU demand in the minute is 900 and US demand is 100. EU spends its 500 and is denied 400 requests; US serves its 100 and leaves 400 unused. The tenant observes 600 successful requests out of 1,000 allowed (60% utilization) while the allocator shows 400 tokens untouched — the quota is available, just parked in the wrong place.

Swipe to pan
Regional lease accountingLeases: each region spends only what it was grantedIllustrative mid-window snapshot; pool 1,000/minGlobal budget allocatorpool 1,000 · granted 700 · left 300chunk 100chunk 100EU lease · 6 chunksallocated600consumed500available100each chunk: TTL 15 s, clamped to the window endUS lease · 1 chunkallocated100consumed40available60unspent tokens die at expiry or window endStatic 50/50 splitEUadmit 500denied 400US100unused 400EU demand 900, US demand 100: 400 denied while 400 idle.Demand-weighted renewalEUadmit 900US100Chunks follow demand: 1,000 admitted, 0 idle.No cross-region atomic transaction: grants are capped by the pool at grant time; each region then spends locally.
Each region spends only what it holds; renewal moves tokens toward demand.

Demand-weighted renewal repairs it: regions renew in 100-token chunks as local remaining falls under a threshold, and the allocator grants from the shared pool in the order of requests. EU asks nine times and US once, so the full 1,000 are used (900 + 100) at the cost of renewal round trips (80–120 ms each in this example, taken before the chunk runs out so the request path never waits). Unspent tokens at lease expiry return to the pool where the window allows. Granularity matters: what can be stranded in a partition is whatever a region holds at that moment. With two 100-token chunks parked in the US, as in F21, that is 200 tokens (20% of G); smaller chunks lower the bound but cost more renewals.

FacetF22 at a glance
TriggerStatic 50/50 split of G = 1,000 with demand EU 900 and US 100.
Visible symptomEU users are denied 400 requests while the global quota is 40% unused; support sees 'limit reached' from a tenant who hasn't reached it.
Root causeLease allocation is not tied to demand; leases are local and can't be spent elsewhere.
DetectionPer-region denial rate vs. allocator unused pool; lease utilization per region; time-to-renewal histograms.
ContainmentManually shift grant weights or issue extra chunks to EU.
RecoveryReturn expired lease remainders to the pool; rebalance for the next window.
PreventionDemand-weighted chunked renewal with a renew-ahead threshold; clamp to window; track stranded tokens.
Guarantee that remainsTotal admitted ≤ G; grants never exceed the pool at grant time.
TradeoffSmaller chunks track demand better but multiply WAN calls; larger chunks save calls but strand more.
F22: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F23 — Lost or duplicated lease grant after retries; fencing and idempotency

The US asks the allocator for a 100-token lease; the allocator grants and debits the pool; the response is lost; the US retries. If the allocator treats the retry as a new request, it debits the pool a second time — 100 tokens are consumed from G and the region only knows of one lease (a 10% waste of G). If the US instead applies both grants while the pool only counted one (a bug in the dedupe path), the region holds 200 against a 100 debit: a 100-token overshoot, 10%.

Two mechanisms close it. Leases are identified by region plus a sequence number: the allocator stores the grant keyed by that id and returns the same lease on a replay (idempotency, with a retention window longer than the longest retry). Each lease also carries an epoch (a fencing token) so that after an allocator failover or a region restart, leases from an older epoch are refused at reconcile. The allocator's own state faces F10: if its grant records are replicated asynchronously, a failover can forget grants made in the unreplicated tail. With a lag window that saw at most k = 2 chunk grants, up to 200 tokens (+20% of G) could be granted again. Hence the bound's last term, 'grant records lost in allocator failover'.

FacetF23 at a glance
TriggerThe allocator's grant reply is lost; the region retries; or the allocator fails over and forgets recent grants.
Visible symptomPool shrinks by double the granted amount (waste), or total admitted ends above G (overshoot).
Root causeGrant requests are not idempotent and grant records are not durable across failover.
DetectionGrant id duplicates; sum of unexpired leases + admitted vs. G in the allocator ledger; epoch mismatches at reconcile.
ContainmentFreeze new grants for the tenant; reconcile ledger vs. regional reports.
RecoveryVoid stale-epoch leases; re-issue from the reconciled remainder.
PreventionIdempotent grant ids with retention, epoch fencing, synchronous or durable grant log where the bound matters.
Guarantee that remainsWith idempotency and fencing: no lease counted twice; with async allocator failover: overshoot ≤ lost grants × chunk (k×100).
TradeoffDurable grant logging costs allocator latency; weaker durability widens the bound.
F23: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F32 — Clock, time-zone and reset assumptions create inconsistent policy application

Fixed windows for P-ANON align to epoch multiples of 10 s, which is the same instant everywhere. But a hypothetical daily plan quota for Atlas was implemented as 'reset at local midnight', each region using its own zone. EU resets at 00:00 CET, US at 00:00 PST, 9 hours apart (UTC+1 vs UTC−8). Atlas sees EU capacity return at 23:00 UTC and US capacity at 08:00 UTC, and the global daily total is no longer one day: in a 24-hour slice that straddles one region's reset but not the other's, the combined admits can exceed the daily quota.

Daylight-saving shifts make it worse: a 'day' is 23 or 25 hours twice a year, and zones switch on different dates, so for a few weeks each year the 9-hour offset becomes 8. The remedy is to define every window as a UTC epoch interval, publish reset to clients as an absolute epoch time or a delay-seconds value, and let the customer's billing timezone live only in the control plane, which translates it to a UTC boundary once. Key-expiry clock semantics were not checked for this page, so the safe design is to give every window its own explicit boundary (a window id derived from UTC epoch time) and use TTL only for cleanup.

FacetF32 at a glance
TriggerA daily quota resets at 'local midnight' in two regions with different zones (and DST).
Visible symptomEU and US reset at different UTC instants; customers see allowance reappear at odd hours; combined daily totals exceed the quota.
Root causeWindows were defined in wall-clock local time instead of UTC epoch intervals.
DetectionReset timestamps per region; sum of admits across any 24 h slice vs. quota; support tickets near DST changes.
ContainmentPin all regions to one UTC boundary immediately.
RecoveryRecompute counters for the current period; credit or debit affected tenants explicitly.
PreventionUTC epoch windows, explicit reset fields, billing-zone mapping only in the control plane, DST tests.
Guarantee that remainsWindows aligned to the same epoch boundary behave identically in every region.
TradeoffA UTC boundary may not match a customer's business day, which is a product conversation.
F32: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
F24, F25, F34

Control Plane and Rollout Failures

Policies are data, and distributing data takes time; the limiter has to decide what to enforce while that time elapses.

There are three clocks at work: how fast a new policy reaches gateways, how long a gateway will keep using one it can't refresh, and how long a rolling deploy leaves two behaviors in production at once. The separation of stale policy from stale counters (I13, I3) is useful here: the counter store might be perfectly consistent while the rules applied to it are an older version. Feature flags face the same propagation problem, and the feature flag system design shows the same pattern from a different angle.

F24 — Stale policy after emergency tightening

At 10:00:00 an operator cuts Atlas's P-TENANT from 1,000/min to 200/min to stop a flood. Gateways poll the control plane every 30 s and cache the snapshot. Until each gateway refreshes, it enforces the old numbers. If Atlas sends traffic at the old limit, the extra admitted in a 30 s staleness window is 1,000/60 × 30 = 500 versus 200/60 × 30 = 100 permitted, i.e. 400 extra requests. A gateway that lost its connection to the control plane can remain stale indefinitely unless a maximum age forces a safe default.

Swipe to pan
Policy control plane and data planePolicy flows out as immutable versions, never in placeControl plane decides; data plane verifies and enforcesControl plane1 Author the change (limit, rule, owner)2 Validate schema and safe ranges3 Stage / canary a small slice4 Audit: who, what, when, why5 Publish immutable snapshot v43push or poll v43Data plane (gateways)Gateway A · verify before applycheck signature and versionv43 > v42 → swap snapshot atomicallykeeps last-known-good v42 until thenGateway B · rejectolder (v41 replay) or bad signature→ rejected, keeps serving v43every decision logs the version usedStale-policy windowFrom publish until the last gateway applies v43,some still enforce v42. A disconnected gateway keepslast-known-good: no instant global revocation.Rollback = publish a higher versionVersions only move forward: re-publish the old rulesas v44. Re-sending v42 would be rejected as older,so a rollback can never be mistaken for a replay.
Control plane publishes versioned snapshots; gateways enforce what they last accepted.

Two separate states are in play: policy (rules) and counters. Stale policy means the right counter is consulted with the wrong parameters; stale counters means the right parameters apply to the wrong state. This incident is the first. A related trap: if the policy version is part of the key (rl:v3:... becomes rl:v4:...), activating the new version also swaps in a fresh, full bucket, handing every principal a one-time allowance; parameters that change in place avoid that. I13 says it plainly: there's no instantaneous global revocation; there is a bound, and the bound is poll interval + jitter + max-age. For a real emergency, use a push channel plus a hard edge-side deny rule, since policy propagation is the wrong tool for a flood happening now.

FacetF24 at a glance
TriggerAtlas's limit is cut from 1,000/min to 200/min while gateways cache policy for up to 30 s.
Visible symptomThe flood continues at the old rate for up to 30 s (≈400 extra requests) and longer on disconnected gateways.
Root causePolicy distribution is eventually consistent and gateways may be partitioned from the control plane.
DetectionPolicy version per gateway vs. latest; propagation-lag percentiles; denial rate step after publish.
ContainmentPush the change; add an edge deny rule for Atlas; drain stale gateways.
RecoveryOnce versions converge, normal enforcement resumes; verify via per-version metrics.
PreventionShort polling plus push, max-age with a safe default, version in decisions, in-place parameter changes where fresh buckets are undesirable.
Guarantee that remainsEach decision is made under a known policy version (I3); freshness is bounded by poll + max-age (I13).
TradeoffFaster propagation costs control-plane load; safe-default-on-stale trades availability for control.
F24: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F25 — Rollback of a bad policy and out-of-order snapshots

Policy v41 ships with a unit mix-up: Atlas's tenant limit becomes 10/min instead of 1,000. Within a minute, deny rates on Atlas jump to 99%. The operator 'rolls back' by re-publishing v40, and gateways ignore it, because they accept only versions greater than the one they hold (v41). Separately, consider a delayed v41 message that reaches a gateway after it has already accepted a later good version (say v42): without ordering checks it overwrites the good config with the bad one.

Rollback must be forward-moving: publish v42 with the contents of v40, and let gateways accept only strictly increasing versions (reject a lower or equal version), with each snapshot immutable and checksummed. The rollout should limit damage before anyone rolls back: a 5-gateway canary of 100 is 5% of traffic, enough to see the Atlas denial rate move from ~1% to 99% and halt the publish automatically. The deny-rate-by-policy-version signal is what turns a bad number into a five-percent incident; a full-fleet instant push turns it into a hundred-percent one.

FacetF25 at a glance
TriggerPolicy v41 has a wrong Atlas limit; rollback publishes a lower version number; a late v41 arrives after v42.
Visible symptom99% of Atlas's requests denied; the rollback appears to have no effect on some gateways; others flip-flop.
Root causeVersion comparison rejects 'older' rollbacks and lacks protection from out-of-order delivery.
DetectionDeny-rate-by-policy-version; gateways reporting different versions; canary auto-halt alerts.
ContainmentPublish v42 with v40's content; pause the rollout; drain stale deliveries.
RecoveryVerify all gateways report v42 and deny rates normalize.
PreventionMonotonic versions, immutable checksummed snapshots, staged rollout with auto-halt, validation of unit/ratio changes.
Guarantee that remainsGateways never move to a lower version; each decision names the version that made it.
TradeoffStaged rollouts slow urgent fixes, so emergencies need a separately-controlled fast path.
F25: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F34 — Rolling deploy changes algorithm or policy version and reshuffles eligibility

Release 4 changes P-ANON from a fixed-window counter (string keys) to a token bucket (hash keys) and bumps the key namespace from rl:v3 to rl:v4. The rollout replaces 100 gateways in 10 batches, two minutes apart: for about 20 minutes both versions run. A client load-balanced across them is judged by two independent sets of keys: up to 20 per 10 s under v3 on the old pods plus a fresh full bucket of 20 under v4 on the new ones. In the worst case the client gets 2× during the mixed period, and each flip hands out a fresh full bucket once.

If, instead, the new code reused the old key names, it would find string values where it expects hashes and the store would return a wrong-type error (standard Redis behavior for commands applied to a key of another type; not re-checked against the docs for this page), which looks like a limiter outage on the new pods. Expand–contract migrations avoid both: ship code that can read both formats first, run the new algorithm in shadow mode (compute the decision, don't enforce, compare), switch enforcement through the control plane rather than through code deployment, and let old keys age out by TTL. Because TTL = time-to-refill plus slack, the old namespace disappears within minutes.

FacetF34 at a glance
TriggerA rolling deploy mixes v3 (fixed window) and v4 (token bucket) gateways for about 20 minutes with different key namespaces.
Visible symptomSome clients enjoy up to 2× allowance; others hit wrong-type errors or sudden denials depending on the pod that served them.
Root causeTwo algorithms and key schemas coexist; each pod enforces independently.
DetectionDecision version label per pod; error class on key type; per-client admit rate by pod version.
ContainmentPause the rollout; route a single version; revert pods to the old algorithm.
RecoveryComplete or roll back; allow old keys to TTL out.
PreventionShadow-mode comparison; backward-compatible readers first; switch via policy, not deploy; namespaced keys.
Guarantee that remainsEach pod is internally consistent; the fleet is bounded by the sum of the two versions' limits during the window.
TradeoffDual evaluation costs extra store work and longer rollouts.
F34: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
F11, F26

Client and Retry Failures

Clients are part of the system: their retries decide whether a denial lowers load or multiplies it.

A 429 (RFC 6585) is a statement about the caller's quota; Retry-After (RFC 9110, a delay in seconds or an HTTP date) is a hint about when trying again is likely to be useful, not a reservation (I5). A 503 means the service or its dependency is unavailable and shouldn't be used as a synonym for 429. The RateLimit header fields are an IETF Internet-Draft, not a published RFC: the working group's source repository still labels the document an Internet-Draft (checked 7 Nov 2026), although the datatracker page itself could not be opened from the authoring environment, so treat the status as 'draft, check before citing'. The practices for clients are in retry and backoff, and the retry storm module shows the production pattern.

F11 — Quota-store timeout: ambiguous write and a double-check on retry

The gateway sends the token-bucket script for U-17, the store executes it (one token debited), and the response is lost on the network; the gateway times out at 20 ms. The gateway cannot tell whether the debit happened. A naive client library retries automatically; the second execution debits again. U-17 paid two tokens for one request, or, if the gateway treats the timeout as fail-open, was admitted while also being charged. Ambiguity is intrinsic: a timeout means 'unknown', not 'did not happen'.

There are two defensible policies. First: do not retry debit scripts at all. A single attempt with a deadline, then the route's fail mode, bounds the error to at most one extra debit per timeout. Second: make the debit idempotent with a stable logical request id. The script records the id with a short TTL and returns the stored decision on replay. The cost: 25,000 script calls/s per shard with a 15 s id TTL is at most 375,000 live ids; at an assumed 60 B each that is 22.5 MB per shard, plus the collision and replay rules (ids scoped per tenant, TTL longer than the retry window, shorter than the bucket's purpose). Pick the first unless the route's retries are unavoidable.

FacetF11 at a glance
TriggerThe script runs on the store, the reply is lost, and the client times out then retries.
Visible symptomU-17 is double-charged (spurious 429 later), or admitted without a tracked debit depending on fail mode.
Root causeA timeout is an ambiguous outcome and retries re-execute a non-idempotent operation.
DetectionRetry counters on limiter calls; duplicate request-id rate; per-user debit-to-admit ratio > 1.
ContainmentDisable automatic retries for debit calls; keep a short deadline and the fail mode.
RecoveryCredit duplicated debits where the id log identifies them; otherwise let buckets refill.
PreventionSingle attempt + deadline, or idempotency key (request id, bounded TTL, tenant-scoped); never client-side blind retries.
Guarantee that remainsNo silent double debit when ids are used (I6); without them the error is ≤ one token per ambiguous call.
TradeoffIdempotency costs memory (≈22.5 MB/shard in this example) and a replay-window assumption.
F11: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F26 — Aggressive retry storm from synchronized Retry-After

Atlas's 10,000 clients are told Retry-After: 1. They all sleep exactly one second and retry at the same instant. At t = 1.000 s, 10,000 requests hit the gateways within a few milliseconds — 100 per gateway — and the tenant's shard. If the planning budget is 40,000 script executions/s, each execution is on average 1 / 40,000 = 25 µs of shard time, so 10,000 simultaneous calls represent 10,000 × 25 µs = 0.25 s of one shard's single-threaded work. A shard is blocked for a quarter of a second, and everything else on it queues.

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.
Backoff with jitter and server-side shedding turn a synchronized spike into a spread.

Denied again, the clients retry again at t = 2.000 s: a heartbeat of synchronized spikes, a pattern that persists until demand falls. With jitter, say uniform over 1–2 s, the same 10,000 clients spread to ≈ 10,000 per second average, a spike that is 25% of one shard's budget rather than a quarter-second stall; with jittered exponential backoff (delay chosen uniformly up to base × 2^attempt) later waves spread wider still. Since Retry-After is a hint rather than a reservation (I5), the server can add jitter to the hint itself so even naive clients desynchronize, and gateways can negative-cache (F13) so repeated denials stay out of the store.

FacetF26 at a glance
Trigger10,000 clients receive Retry-After: 1 and retry on the second.
Visible symptomPeriodic spikes of 10,000 requests; shard blocked ≈ 0.25 s at each spike; collateral latency for other tenants; 429 count rises, not falls.
Root causeSynchronized retry timing from a deterministic hint creates correlated arrivals.
DetectionSpiky arrival histogram at 100 ms resolution; retry-attempt-count distribution; request interarrival autocorrelation at 1 s.
ContainmentAdd jitter server-side to Retry-After; enable gateway negative caching; shed the retrying tenant at the edge.
RecoveryWaves flatten within a few cycles; clients need updated backoff behavior.
PreventionDocument jittered exponential backoff with a cap and retry budget; randomize the hint; client SDK defaults.
Guarantee that remainsPer-key admission is unchanged by retry timing; only the load on the limiter changes.
TradeoffMore jitter spreads load but increases the time to retry for well-behaved callers.
F26: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
F30, F31

Observability and Bypass Failures

The last two incidents are about the edges of the limiter's authority: what it only reports, and what it does not see.

A decision log, a metric and a usage report are views of what the limiter did; they are not the limiter. A bypass is the opposite: a path that avoids the limiter and therefore avoids its views. Both are dangerous for the same reason, which is that the dashboards stay calm. The correctness invariants I12 and I14 are the guardrails: metrics are not a ledger, and privileged routes are narrow, authenticated, audited and capped.

F30 — Allowed telemetry is lost or duplicated; observability is not the authority

Every decision is also published to an event pipeline for dashboards and usage reports. At 15% loss in the pipeline the dashboard shows Atlas at 850 admitted in a minute when the store actually admitted 1,000; at-least-once redelivery on another day shows 1,300. Neither number says anything about what the limiter allowed: the script on the store decided, atomically, before any event was emitted. Counting events is accounting; the decision is enforcement.

Confusing the two causes real harm in both directions. If enforcement read the lossy metric, then with a 15% undercount the limit of 1,000 would fire only after about 1,000 / 0.85 ≈ 1,176 true admits, an overshoot of 17.6% caused by telemetry. If the usage report feeds billing (I12), undercounts lose revenue and duplicates over-bill. The remedy is separation: the store-side script maintains the authoritative window counter and emits a monotonic per-window total; the pipeline carries per-decision events with ids for dedup, and a reconciler compares the sums and raises a discrepancy rather than correcting enforcement. An alert named 'telemetry gap' is a different page from one named 'limit exceeded'.

FacetF30 at a glance
TriggerThe decision-event pipeline drops 15% of events (or redelivers duplicates) while the limiter's decisions are correct.
Visible symptomDashboards show Atlas at 850 or 1,300 per minute against a true 1,000; reports and invoices disagree with enforcement.
Root causeTelemetry is asynchronous and lossy; it was used as if it were a ledger.
DetectionReconciliation of store-side window totals vs. event counts; gaps in event sequence ids; pipeline lag/drop counters.
ContainmentDo not change enforcement; flag the affected window's data as approximate; fix the pipeline.
RecoveryRe-derive usage from store-side totals; backfill or mark the gap.
PreventionNever feed enforcement from telemetry; event ids for dedup; separate ledger for billing-grade counts.
Guarantee that remainsAdmission correctness is unchanged; only the visibility is degraded.
TradeoffA ledger-grade accounting path is more expensive than best-effort metrics.
F30: hypothetical Northstar scenario; every number is a teaching input, not a measurement.

F31 — Internal bypass or privileged route floods the backend

To keep health checks and an internal reindex job from being throttled, someone adds a rule: if a request has X-Internal: true, skip the limiter. The reindex job runs away at 5,000 req/s against a backend that handles 2,000 comfortably (250%). Nobody counted it, and Birch's catalog calls slow to a crawl. Worse, the header is a header: once it is known, an attacker can add it.

A bypass is a different route, not a skipped check. Internal callers authenticate with a service identity (mTLS or signed tokens), pass through a narrow route with its own small cap (for instance 200 req/s, 10% of the backend's 2,000), and every use is audited with the identity and reason. Health probes get an even smaller allowance on a path that exposes nothing else. The alert is on the privileged route's usage, so the cap's own saturation is visible. I14 sums it up: exemption with accounting, not exemption from accounting.

FacetF31 at a glance
TriggerAn internal job with an unmetered bypass runs at 5,000 req/s (and the bypass signal is a client-set header).
Visible symptomBackend at 250% of comfortable load; customer latency rises; the limiter shows nothing because it was skipped.
Root causeThe exemption removed the request from every control and relied on an unauthenticated marker.
DetectionBackend load vs. admitted-and-counted load gap; privileged-route volume; requests carrying the marker from external sources.
ContainmentRemove header-based bypass; apply the privileged cap; throttle the job.
RecoveryFix the job; replay or pause the reindex under the new cap.
PreventionAuthenticated privileged route with a separate cap, audit log, and alerts on its own saturation.
Guarantee that remainsOrdinary traffic stays limited; privileged traffic is bounded by its own cap.
TradeoffA cap on internal jobs can slow necessary operations; teams must plan for it.
F31: hypothetical Northstar scenario; every number is a teaching input, not a measurement.
Operations

How to Run This Limiter: Alerts, Signals and Game Days

The incidents reduce to a small set of signals, a few drills and a handful of standing rules. If those exist before the first incident, most of the 35 become short events rather than long ones.

Signals worth paging on, and signals worth a ticket

SignalMeaningTypical responseIncidents
Limiter error/timeout rate and breaker state by shardThe authority is unreachable or slow; fail modes are now decidingPage; verify per-route fail mode is activeF09, F12, F20
Pool wait time and in-flight limiter callsQueueing is building before timeouts showPage if sustained; open breaker for the shardF12
Per-shard script rate, skew vs. fleet medianA hot key or tenant is concentrating loadTicket; page if over budget (40,000/s in the example)F13, F26
Eviction counter non-zero; memory ≥ 85% of maxmemoryEnforcement may be silently resettingPage on any eviction; isolate or resizeF14, F15, F35
Keys with TTL = −1 (sampled)Lifecycle bug: counters never expireTicket; sweep and fixF04
Replication lag and failover eventsThe unreplicated tail is the over-admission boundAnnotate dashboards; review lag budgetF10
Admit/limit ratio per key and per windowTrue over-admission (allowance failure)Investigate the rule and the authorityF01–F03, F07, F21
Telemetry sequence gaps; event vs. store-total mismatchObservability failure, not allowance failureTicket; do not change enforcementF30
Deny rate by policy version; propagation lagBad or stale policyPage on step changes; halt rolloutF24, F25, F34
Lease remaining, grants vs. pool, stranded tokens by regionMulti-region budget is skewing or unreachableTicket; page on partitionsF21, F22, F23
Privileged-route volume vs. its capBypass is being used or abusedPage near capF31
Denials attributed to concurrency, IP, account, tenant, userWhich rule refuses whomSupport dashboard; fairness reviewF16, F18, F19, F27
Signals that map the 35 incidents to operational alerts. Thresholds are examples to be set from your own load tests.

Service-level objectives for the limiter are best written about its decisions, not its uptime: the fraction of admission calls answered within the p99 goal (a low single-digit-millisecond target in the same region, a goal to measure rather than a benchmark), the fraction answered by the authority rather than by a fail mode, and the over-admission ratio (admitted ÷ allowed) for each strict policy. A limiter that answers 100% of calls from a fail mode is 'available' and worthless; the second measure makes that visible.

Game days that exercise the failures

  • Kill primary R during a load test at peak ratio and measure the over-admission tail against replication lag (F10), then confirm routes follow their fail modes (F20).
  • Run a hot-tenant flood of 75,000 attempts/s at one tenant and confirm Birch's p99 stays inside target and the negative cache cuts store calls by well over 90% (F13).
  • Replay a synchronized 10,000-client retry wave with and without server-side jitter (F26).
  • Create 50,000 bogus principals/s against pre-auth routes and watch memory, evictions and script rate (F14, F15, F35).
  • Partition EU from US and the allocator; check that admitted ≤ the documented bound for the lease design (F21–F23).
  • Publish a deliberately bad policy to the canary only; confirm auto-halt, then roll forward with a higher version (F24, F25).
  • Run a rolling deploy that changes the key namespace in shadow mode and diff decisions (F34).
  • Corrupt the telemetry pipeline on purpose and confirm enforcement and billing totals are unchanged (F30).
  • Restore a snapshot from five minutes ago in staging and measure which limits notice (F33).
  • Retrying a debit script after a timeout without an idempotency key (F11).
  • Defaulting unknown errors or a missing key to 'allow' or to 'block' without a recorded decision (F09, F15).
  • Using telemetry as enforcement or as the quota ledger (F30).
  • Creating per-principal state before the credential is verified (F35).
  • Header-based bypass of the limiter (F31).
  • Calendar-local windows in a multi-region quota (F32).
  • Advertising a global limit without stating the overshoot bound and the partition behavior (F21).

Verification ledger and open items

Redis behaviors were checked on 6–7 Nov 2026 against the official documentation sources (the redis/docs repository that publishes redis.io): atomic script execution and blocking, effects replication as the default since Redis 5.0 and the only mode since 7.0, with TIME usable inside scripts under it (scripting intro); asynchronous replication and the limits of WAIT (replication page); no-guarantee-of-retained-acknowledged-writes under Sentinel (Sentinel page); same-slot requirement and hash tags (cluster specification); declared key arguments for scripts (scripting intro); eviction policy list, noeviction and volatile-* behavior (eviction page); RDB snapshots vs. AOF everysec loss window (persistence page); SET-style commands clearing key timeouts (EXPIRE page); script maximum execution time (programmability overview). Not re-verified: the exact CROSSSLOT error text, wrong-type behavior in F34, the evicted-keys counter name, key-expiry clock semantics, and the HTTP/IETF specifications (RFC 6585, RFC 9110), which could not be reopened from the authoring environment; the RateLimit header draft's status was checked only through the working group's source repository. All numbers on this page are hypothetical teaching inputs.

Key takeaways
  1. 01Atomicity is per instance; durability, cross-slot atomicity and cross-region exactness are separate questions with separate bounds.
  2. 02Name every bound: unreplicated tail (F10), 0.25 s of leased capacity (F13), about 4 tokens of negative-cache staleness (F13), +20% leasing carry-over (F21).
  3. 03Every strict route needs a pre-decided fail mode, and 'available' must be defined per route.
  4. 04Telemetry failures and allowance failures need opposite responses.
  5. 05Clients are part of the system: jitter and retry budgets are limiter features.