Design a rate limiter
A distributed rate limiter in front of a public API: 50k requests/s across 20 gateway nodes, per-API-key limits, under 2 ms added latency.
Real output · generated by the tool on 27 Sep 2026 · unedited
How to read this document
- Start with the executive recommendation — the decision and why.
- Then "Alternatives considered" — what was rejected and the trade-off.
- Then "Failure modes and recovery" — what breaks and how it heals.
- Capacity calculations and the rollout plan are reference detail; skim on first read.
A * on a section heading marks a section that was scored against a blueprint quality gate before the document was returned.
Depth: full — whole subsystem, hard latency invariant, a correctness bound, and a fail-open blast radius.
Distributed Rate Limiter — Public API Gateway — Decision Package
Executive Recommendation *
Recommended: enforce per-API-key limits with demand-driven distributed credit leasing. Each gateway node holds leased credit — a quantity of permitted requests for a (key, window) — and grants it on demand rather than being pre-seeded with a fixed fraction of the plan limit. A single fold owner per key shard, held by one of the 20 gateway nodes and selected as hash(key_id, window_id) mod fleet_size, is the only authority that issues credit, and it never issues more than the window's remaining unallocated allowance, so the fleet-wide invariant sum(outstanding credit) ≤ window allowance holds at all times. Every 25 ms each node publishes cumulative spend, current demand and its lease state; the fold owner sizes grants from advertised demand plus a headroom factor, and rebalances unspent credit away from nodes that are not using it. A node whose held credit is about to exhaust issues a fast-path grant request to the fold owner with a hard 1 ms deadline; if the deadline expires it denies. The decision path is otherwise a memory read, so the 2 ms added-p99 target is met by construction, and because no external tier sits in the decision path, "the limiter is unavailable" degenerates into "the node keeps enforcing with the credit it holds" rather than into an outage. Accuracy is bounded rather than hoped for: over-admission ≤ 2%, because a single owner grants the reserve and the allowance invariant caps total outstanding credit, and under-admission ≤ one reconciliation round of demand redistribution, which is 2.5% of a 1-second window and 0.04% of a 60-second window. The honest price is that the team owns a distributed leasing protocol with epoch fencing, and that the fast-path request is the one place the p99 budget can be touched; both are made explicit below with fenced epochs, idempotent grant semantics, and a measured ceiling on fast-path usage. Recommended the whole leasing mechanism is provisional until measured: P0 includes a decision gate that refutes or confirms the traffic shape it exists to serve (A55), and if refuted the v1 ships without the leasing protocol at a saving of roughly three weeks of the schedule.
Requirements and Assumptions *
Evidence state: no system evidence is attached to this session and no reference library is loaded. Nothing in this document is Verified against a running system. Every line is your stated requirement, my arithmetic, or a labelled assumption. Attaching load-balancer per-second counters, plan configuration, and 429-related incident notes would raise the traffic and availability lines from assumed to verified.
Labels in use: Stated (your requirement, not verified in a running system), Calculated (derived here from visible inputs, formula shown), Assumed (A#) (temporary, with impact and validation trigger), Recommended (my judgement tied to a stated constraint), Unknown (requires investigation before commitment).
Functional
Non-goals
Traffic
Quality
Recovery
Constraints
Governance
Unresolved blocking questions
X1 Recommended — confirm target precedence (Q8). X2 Unknown — what 5% is measured against and which error direction is worse. X3 Unknown — window semantics, permitted burst size, Retry-After meaning. X4 Unknown — regional topology. X5 Unknown — counter durability and any billing-grade requirement; the only question that changes the architecture rather than a parameter.
A39 — T = 25 ms round; grants sized from advertised demand plus headroom. Impact: under-admission is bounded by one round of demand redistribution rather than by a permanent share deficit. Validation: staging load test with synthetic bursty and connection-pinned keys.
A40 — R = 2% of each key's window allowance is held unallocated, granted only by the fold owner for fast-path requests. Impact: over-admission ≤ 2% while the single-owner rule holds. Validation: adversarial simulation, all nodes bursting one key simultaneously.
A41 — the reconciliation channel is an existing low-latency at-least-once bus, scopable to key-shard topics; measured RTT ≤ 1 ms. Impact: if absent, this design's operational burden equals Option B's. Validation: A32.
A42 — Retry-After is an integer, and for waits under 1 s it is 0 with a millisecond companion header. Impact: without this a sub-second window cannot be expressed and clients idle up to 40× too long. Validation: HTTP conformance check on the 429 path.
A43 — 429 fraction ≈ 1%. Impact: 429 log volume is 8.64 GB/day and ~0.26 TB at 30 days, so the cost lever is retention days rather than bytes; at a 10× higher 429 rate it would be ~2.6 TB and retention becomes a sizing decision. Validation: access-log sampling in P0.
A44 — every request of a key costs 1 unit. Impact: a cost-weight dimension changes the accounting key and the grant arithmetic.
A45 — per-key window phase offset = hash(key_id) mod W, deterministic and identical on all nodes, derived from wall clock, never from a monotonic clock. Impact: removes the boundary retry herd.
A46 — deploys are staggered so no more than 3 of 20 nodes restart within one window. Impact: restart orphaning is bounded per node. Validation: deploy tooling.
A47 — demand advertisement includes a headroom factor h = 1.25. Impact: affects grant efficiency only, never over-admission, because grants are capped by the allowance invariant. Validation: P2 fast-path utilisation test.
A48 — fast-path grant requests stay under 0.5% of requests, alert at 1%. Impact: above 1% the p99 budget is exposed. Validation: continuous monitoring from P2.
A49 — aggregate fleet admission ceiling = min(1.5 × peak, backend tested capacity); 1.5 × 50,000 = 75,000 req/s provisional. Impact: bounds fail-open exposure. Validation: backend saturation curve, see A52.
A50 — pinned retention: buckets and reports memory-only, life = one window plus two rounds; limit events 30 days; config audit 13 months.
A51 — bus RTT ≤ 1 ms; fast-path deadline 1 ms hard.
A52 — backend saturation knee — Unknown; the ceiling cannot be set correctly without it. Impact: the exposure bound is provisional. Validation: a backend load-to-failure test before P3.
A53 — hashed key ids are version-prefixed; the old hash key version is retained for the full retention period of the events it covers, then destroyed.
A54 — the backstop floor and fleet ceiling are implemented in a layer surviving limiter middleware failure — either a gateway-level pre-module with its own process or an edge L7 rule — not inside the limiter.
A55 — connections are pinned to nodes for minutes at a time, so per-key traffic is unevenly distributed. Impact: this is why credit is demand-driven rather than pre-allocated. Validation: P0 concentration measurement, with a decision gate.
A56 — the grant and report envelopes are the only source of admission credit, so a forged grant is indistinguishable from a legitimate one unless the path is authenticated. Impact: the grant path is a control plane and requires mTLS and strict schema rejection. Validation: security review before P2.
A57 — tenant isolation is per-key and per-backstop, but fold shards are shared hardware, so hard isolation of accuracy and latency depends on top-K isolation working. Impact: a hot key on a shared shard can delay folding for co-tenant keys if top-K isolation fails. Validation: P2 hot-key test asserting p99 fold age for non-top-K keys stays under 2 rounds.
Capacity Calculations *
All figures are order-of-magnitude sanity checks derived from the assumptions above, not benchmarks and not measurements of your system.
Accuracy budget.
- Over-admission. Formula:
over = credit granted beyond remaining allowance + reserve contention. Under the single-owner rule, reserve can only be granted byhash(key_id, window_id) mod fleet_size, so reserve contention is structurally impossible and the allowance invariant caps total outstanding credit. Calculated: over-admission ≈ 0 at steady state and ≤ R = 2% in the worst case where a boundary-spanning fold outage forces a re-grant the owner has not yet observed; the owner withholds grants from unobserved nodes for one round, bounding the residual to one round of reserve. - Under-admission. Formula:
under = demand redistribution lag + fast-path deadline misses. Grants are sized from advertised demand, so a connection-pinned client converges to its full allowance within one round instead of being capped at a fixed fraction. Calculated: lag ≤ T ÷ W = 25 ms ÷ 1,000 ms = 2.5% for a 1-second window, and 25 ms ÷ 60 s = 0.04% for a 60-second window, plus fast-path deadline misses below 0.5% of requests (A48). Total ≤ 3% against the 5% ceiling, with the previous 93% deficit for a pinned client removed entirely. - Restart. Formula:
restart under-admission = orphaned held credit ≤ 1/fleet of the window allowance;restart over-admission = 0. Calculated: ≤ 5% under-admission per restarted node, 0 over-admission, because a restarted node seeds zero credit and cannot reclaim orphaned credit, and because the allowance invariant is never exceeded by a returning node. This single rule replaces the previously contradictory 100% and 15% figures. - Implication: the error is one-sided toward spurious 429s, the support-ticket direction, unless X2 resolves the other way — in which case set R = 0 and the reserve becomes a pure fast-path buffer with no over-grant capacity.
Architecture *
Context view
Rendering…
Recommended the limiter sits after authentication in the middleware chain, so an invalid key never reaches the limiter and never produces a 429 (N3). The backstop floor and aggregate ceiling sit in the edge layer with an independent failure domain (A54), so a limiter crash loop cannot disable them.
Container view — one gateway node
Rendering…
Component notes. Local bucket map: one bucket per active key per window holding lease size, local spend, remaining credit, the highest sequence seen per peer, and a self-grant flag. Hard-capped by LRU at 50,000 buckets per node, evicting only zero-credit buckets and reloading authoritatively on next sight. Fold owner role: one of the 20 nodes holds the grant authority for each key shard, selected by hash(key_id, window_id) mod fleet_size; it is a role, not a tier, and every node runs the code. It is the only issuer of credit, including the reserve, and therefore the only writer of the allocation ledger for its shards. Fast path grant client: issues a bounded request with a hard 1 ms deadline when held credit is nearly exhausted; on expiry it denies. Inbound grant applier: applies grants idempotently by (key_id, window_id, grant_seq). Concurrency model: Recommended bucket mutation uses per-bucket atomics with no shared lock on the decision path — the request path performs an atomic compare-and-decrement against the bucket's remaining credit, and the grant applier publishes grants as whole-bucket replacements ordered by grant_seq, so a grant is a monotonic increase in credit that never requires the request path to block. Calculated: a map-level lock would put fold latency directly into p99, which the 5.6-request in-flight budget cannot absorb, so a global lock is excluded structurally.
Request and Event Flows
Critical read path — admission decision
- Request arrives; TLS terminated; key authenticated and resolved to a version-prefixed hashed key id; invalid key short-circuits to 401 before the limiter (N3).
- Limiter computes the key's window id from wall-clock time with the per-key phase offset (A45) and looks up the bucket. Miss → insert an empty bucket; on first sight of a key within a window the node requests credit rather than granting itself a fixed share.
- Atomic compare against remaining credit across both windows. If credit suffices, decrement and admit.
- If credit is exhausted and the fast path has not already been taken this round, issue a fast-path grant request to the fold owner with a hard 1 ms deadline, then re-evaluate. If the deadline expires, deny — the local limiter is available, so this is a genuine over-limit verdict (A23), not a fail-open case.
- Deny path: emit 429 with
Retry-Aftercomputed to the maximum boundary across all configured windows that could deny, not only the ones that did; for waits under 1 s emitRetry-After: 0plusX-RateLimit-Reset-Ms; minimum integer 1 otherwise. The body names the denying window and a correlation id, and never the limit. If enforcement is degraded, addX-RateLimit-Degraded: true(F11). - No lock, no allocation, no network call on the common path.
Critical write path — reconciliation round
- Every 25 ms each node publishes, for keys it holds or touched:
{key_id, window_id, node_epoch, seq, cumulative_spend, demand, held_credit, self_grant}. Cumulative counts, not deltas. - The fold owner for each shard merges by
(key_id, window_id, node_epoch)with max-sequence-wins within an epoch and sums across live epochs, so a restarted node's new lower cumulative count is added, not discarded. - The owner recomputes allocations: total allowance for the window, credit currently outstanding, per-node advertised demand. It grants credit sized from demand times headroom h (A47), and rebalances unspent credit away from nodes that have not spent it. Invariant enforced here:
sum(outstanding credit) ≤ window allowanceat all times, including reserve. - Reserve is released at most once per key per window, only by the owner, only from the unallocated pool.
- Node epochs are reaped at window rollover plus two rounds; a dead epoch's orphaned credit returns to the pool at rollover, never mid-window.
- A node absent for a round is withheld from grants for one full round after it reappears, so the owner never double-grants against an unobserved self-grant.
Failure path — fold owner unreachable
- The decision path is unaffected while the node holds credit; this is the point of the design.
- After 2 missed rounds (50 ms), nodes stop requesting fast-path grants and stop expecting rebalancing. Held credit is consumed normally.
- A node may self-renew a lease it already holds at the same size, one round at a time, without coordinator contact. This is safe because renewal does not increase
sum(outstanding credit). - At a window boundary with no fold contact, each node may self-grant exactly
1/fleetof the policy-known allowance for that key, and must set theself_grantflag in every subsequent report. The fold owner, on return, withholds grants from any node whose self-grant it has not yet observed for one full round, bounding the residual double-grant to one round of reserve. - After 30 s, nodes enter degraded mode: no self-renewal beyond held credit,
enforcement_degradedfires,X-RateLimit-Degradedis set on responses, and the edge ceiling (F10) becomes the binding bound. - Detection is independent of the accurate path:
reconciliation_freshness_p99andreports_received_per_node_per_roundboth alert, at < 2 min (R2). - Recovery is verified by probe — every node's fold age under one round, grant authority confirmed for every shard, and lease totals reconciling to the allowance — not by the bus reporting healthy.
Data Model and Ownership
Partition key and hot-key analysis. The bucket map is partitioned by key_id alone, and each bucket has exactly one credit-issuing owner, which is why no cross-node transaction or shared lock exists in the hot path. Calculated busiest-key load (A9): 150 req/s ÷ 20 nodes = 7.5 req/s/node of local work, which is nothing. The concentration risk is entirely in the fold, where the busiest keys generate the most report traffic and the most grant arbitration: the fold owner for a hot shard serialises ~7.5 grants/s plus merges across 20 peer reports per round, well inside a 25 ms round. Recommended fold shards are assigned so a hot key's fold work is owned by one node rather than performed by all twenty, and top-K isolation prevents a single key from delaying folding for others (Q13), verified by asserting p99 fold age for non-top-K keys under 2 rounds during a hot-key event.
Retention, evolution and backfill. Buckets and reports are memory-only, life = one window plus two rounds (A50). Limit events are the only durable artefact at request granularity, retained 30 days, aggregated thereafter. The report envelope carries a schema version and must tolerate unknown fields; a node must accept reports from a peer running one release older for the duration of a staggered deploy. Adding a window kind is additive; changing window semantics is breaking and requires a dual-run period computing both schemes and enforcing one. Backfill: none for counters — nothing is stored. Backfill applies to policy only: derive each key's limits from the current plan table, validate against 30 days of traffic to confirm no key would be newly throttled, and stage the result as a reviewable artefact before activation.
Interface Contracts
Synchronous API
Retry safety, stated plainly. Safe to retry: policy fetches, report publication, grant requests, and a 429'd client request after its Retry-After has elapsed. Consumes capacity again, so a retry repeats it: any admitted request — the one place where duplicate execution has a resource consequence, and why T6 is load-bearing. Cannot duplicate a financial outcome: nothing, because the limiter never charges, reserves or grants entitlement — unless X5 resolves as billing-grade counters, at which point report and grant idempotency becomes an invoicing correctness requirement and the epoch-aware merge becomes mandatory rather than merely good practice.
Asynchronous streams
Alternatives Considered *
Why the winner won, and what would flip it. A′ beats B on a narrow, decisive axis: the grant authority is a role on hardware that already exists and already has a definition of "healthy". B's accuracy is inherently the same, because A′ adopted B's single-owner grant mechanism; the difference is whether the single writer is a role on the gateway fleet or a dedicated tier with its own deployment, upgrade and page-out-of-hours story. With 2–4 engineers, no dedicated SRE and one quarter (C2, C3), a new tier is the thing most likely to become the incident, so the trade is a more complex protocol inside a fleet the team already operates. A-fixed is not a straw man and is not discarded: it was the original design, it fails F6 only for pinned traffic, and it remains the v1 design if P0 shows traffic is evenly spread — in which case a 1/fleet share is arithmetically correct and the leasing protocol has no work to do. All four options share the node-side enforcement core, so the decision is cheap to defer until the coordination layer is written and expensive after.
Failure Modes and Recovery *
Recommended three drills before GA and quarterly thereafter: bus partition, double-failure killing the bus and policy service together, and a limiter middleware crash loop with the edge backstop alone in the path. Recovery is verified by probe, never by a component reporting its own health.
Security and Operability
Trust boundaries: client → edge (untrusted; TLS plus auth); edge → gateway (trusted network, mTLS recommended); gateway → bus (control plane, not data plane, Assumed A56 — a forged grant is indistinguishable from a legitimate one unless authenticated, so the report and grant paths require mTLS, version-prefixed key ids and strict schema rejection rather than partial application); admin API → policy store (privileged, strong authN plus audit); gateway → observability (never carries raw keys); break-glass kill switch (separate credential, named principal, logged to audit with reason).
Data classification and tenancy. API keys are credentials and may be personal-data-adjacent (G2). Tenant isolation is implemented as per-key bucket isolation, per-key backstops and fold sharding. Calculated: a hostile tenant can consume its own allowance and add fold fan-out for its own keys. Assumed (A57): the guarantee that one tenant cannot affect another's accuracy or latency is conditional — fold shards are shared hardware, so it holds only while top-K isolation works; the validation trigger is the P2 hot-key test asserting p99 fold age for non-top-K keys stays under 2 rounds. Calculated tenant-isolated state: 10⁴ buckets per node at 10⁵ keys, rising to 4 × 10⁴ buckets × 64 B = 2.56 MB/node at 4× growth, which remains trivial. Limits are configuration, not customer data, and are no longer disclosed in 429 bodies, so — Calculated from the interface contract that the 429 body "never [carries] the limit" — a stolen key can learn only its own account's limit and cannot fingerprint another plan's.
Secrets, encryption, backups, supply chain, and the erasure-rotation interaction. Key hashing uses a keyed hash with a version prefix (A53). Rotation adds a new version; the old version is retained for the full retention period of the events it covers and is then destroyed. That is what makes erasure and subject-access answerable: a request for a person's throttling history is served by hashing their known keys under each retained version and returning matching limit events, and an erasure is executed by deleting matching rows and recording the erasure in the audit trail. Configuration is encrypted at rest with rotation support. Backups cover policy and audit only — counters are deliberately not backed up, which must be stated in the DR documentation so it is not later "fixed". The new supply-chain dependencies are the bus client and the report and grant codecs; both need pinned versions and review, because the grant envelope sits on the enforcement control plane and an unrecognised schema must be rejected outright rather than partially applied.
SLIs, SLOs and alerts.
Metrics follow RED on the limiter path and USE on the bus. Traces propagate a correlation id and the hashed key id, never the raw key. Recommended one dashboard per SLO plus a single enforcement-health dashboard answering "is every node enforcing the right limits right now" in one glance — the dashboard an on-call engineer needs during a fail-open event. Ownership: gateway platform owns the limiter, the fold role and the bus; API platform owns policy, key index and audit; SRE owns alerting and drills; the edge team owns the backstop and ceiling. Cost monitoring tracks per-node compute, message count and log volume separately, with attention on message count, since T and the fast-path rate trade volume for accuracy.
Evolution and Scaling Triggers *
Rollout Plan
Recommended v1 ships P0–P3 only. P4 and P5 are deferred to v1.1, because P0–P5 plus a parallel P4 does not fit 2–4 engineers in one quarter with no dedicated SRE (C2, C3). P4 — the self-serve config API — should be added back when plan changes exceed roughly 10 per week or when manual edits exceed a few per day and start consuming support time.
P0 carries a downscope decision gate. The leasing protocol exists to serve one traffic property: uneven, connection-pinned per-key distribution (A55). P0 measures it directly, and the result decides the architecture rather than merely informing tuning.
- Refuted: if the p95 of per-node-per-key concentration across keys is within 1.5× of the uniform expectation (keys ÷ fleet), traffic is evenly spread, a 1/fleet share is arithmetically correct, and A-fixed has no accuracy defect. Then v1 ships without the leasing protocol — no fold owner role, no fast-path grant client, no epochs — retaining a fixed fair share plus the reserve, which removes roughly three weeks from the schedule, removes the top technical risk (R1), and makes P2 a reconciliation-hardening phase rather than a leasing-hardening phase.
- Confirmed: if the concentration p95 exceeds 1.5× uniform, proceed with A′ as specified.
- Owner and evidence: Gateway platform, from P0 per-node-per-key measurement, reviewed with the same sign-off as the X2 and X3 decisions. The gate is recorded as a written decision with the measured distribution attached, not as an implicit judgement.
Risks and Open Decisions *
Architecture Decision Records
ADR-1 — Demand-driven distributed credit leasing with a single owner per key shard, conditional on measured traffic skew.
Context: a 2 ms added-p99 budget at 2,778 req/s/node permits ~5.6 in-flight requests per node; fail-open and 5% accuracy must coexist; and if traffic is connection-pinned, a fixed fair share would cap a pinned client at 7% of its plan. Decision: each node holds leased credit acquired on demand; one fold owner per key shard, selected as hash(key_id, window_id) mod fleet_size, is the only issuer of credit including a 2% reserve; grants are sized from advertised demand times headroom; the invariant sum(outstanding credit) ≤ window allowance is enforced by that single writer; the common decision path is a memory read with a bounded 1 ms fast path taken by under 0.5% of requests. The decision is conditional: P0 measures per-node-per-key concentration, and if the p95 ratio to uniform is within 1.5×, the protocol is dropped from v1 in favour of a fixed 1/fleet share. Status: Proposed — blocked on A55 measurement, X2 and X3. X5 would supersede it. Consequences: p99 is met by construction on the common path, fail-open is a degradation rather than an outage, over-admission is bounded by reserve contention, under-admission by one round of demand redistribution, and the team owns a distributed leasing protocol with epochs and owner failover — a cost that is only paid if the P0 gate confirms it is needed. The fast path is the single place the latency budget can be touched, which is why its utilisation is a monitored SLO. Alternatives rejected: fixed fair-share seeding (structurally caps a pinned client at 1/fleet + reserve = 7%, failing F6 for pinned traffic — but retained as the v1 design if P0 shows traffic is uniform); a dedicated replicated allocator tier (Option B) (same grant mechanism, but a new tier to deploy, upgrade and page for, unjustified at 2–4 engineers with no SRE); synchronous key-owner evaluation (Option C) (fails the latency invariant outright, 1 ms batch plus round trip consuming 50–75% of the budget before load).
ADR-2 — Cumulative counters with monotonic sequence, merged per epoch and summed across epochs.
Context: the bus is at-least-once with no global ordering, and nodes restart. Decision: publish cumulative spend per (key_id, window_id, node_epoch) with a monotonic sequence; max-sequence-wins within an epoch and sum across live epochs; reap dead epochs at window rollover plus two rounds. Status: Accepted. Consequences: duplicates and reorders are harmless, a restarted node's lower cumulative count is added rather than discarded, and gap detection becomes possible; records are slightly larger and a gap requires a wait or a snapshot fetch. Alternatives rejected: additive deltas, which double count on the first duplicate; and a plain max-sequence merge over (key, window, node_id), which permanently undercounts a restarted node.
ADR-3 — Counters are ephemeral; losing one window is acceptable. Context: durable counters imply ~20,000 writes/s average and 6.73 TB/year. Decision: no durable counter store; only policy, key index and audit are durable. Status: Proposed — blocked on X5. Consequences: cheap and simple, and it is what makes Option C unnecessary; if usage is billed from this path the decision inverts and the design must move to single-writer-per-key durable accounting. Alternatives rejected: a durable counter store, which buys nothing the stated requirements ask for and costs an operational tier.
ADR-4 — Fail open per request, bounded by a backstop floor and an aggregate ceiling outside the limiter middleware. Context: F5 and abuse prevention are in direct conflict, and a per-key backstop alone leaves a computed 4,000× exposure. Decision: fail open on any limiter-internal failure, always apply a coarse per-key local cap at a generous multiple of the plan limit, and additionally enforce an aggregate fleet admission ceiling in a layer with an independent failure domain that survives a limiter crash loop. Status: Accepted for the principle; the ceiling's value is blocked on A52. Consequences: fail-open is no longer a silent unlimited-traffic mode, exposure is bounded by the ceiling rather than by per-key arithmetic, and the backstop is deliberately inaccurate and generous. If the ceiling is ever enforced inside the limiter middleware, a crash loop disables both bounds and the design silently reverts to unbounded exposure — which is why A54 is stated as a constraint, not a preference. Alternatives rejected: fail-closed, which turns a limiter outage into an API outage; pure fail-open with only a per-key backstop, which is a 4,000× exposure.
ADR-5 — Fixed windows per key with a wall-clock-derived phase offset.
Context: aligned windows create a fleet-wide retry herd; a monotonic clock cannot produce a window id that 20 nodes agree on; and reconciliation keys on (key_id, window_id). Decision: window identity is derived from wall clock with bounded ≤ 50 ms skew plus a per-key offset of hash(key_id) mod W, identical on every node; monotonic clocks are used only for interval and round measurement. Status: Proposed — blocked on X3. Consequences: the retry herd disappears and Retry-After stays honest, at the cost of per-key minute boundaries that do not align to the wall clock and boundary burst behaviour slightly more permissive than a sliding window. Alternatives rejected: globally aligned windows (retry herd); monotonic-clock window identity (incoherent across nodes, and it breaks the reconciliation key); sliding windows or per-request smoothing (materially more state per key for a bound the fixed window already meets).
ADR-6 — Retry-After computed to the maximum boundary across all windows that could deny.
Context: F3 requires Retry-After; a value computed only from the windows that denied sends clients straight into a second 429; and integer seconds cannot express a sub-second window. Decision: compute the wait to the maximum boundary across all configured windows that could deny; emit Retry-After: 0 plus X-RateLimit-Reset-Ms when the wait is under 1 s, otherwise integer seconds with a minimum of 1; the body names the denying window and a correlation id, never the limit. Status: Accepted. Consequences: interoperable, self-diagnosing and honest about sub-second windows; depends on per-key offset windows (ADR-5) to avoid synchronised retries. Alternatives rejected: an HTTP-date Retry-After, which is legal but creates clock-skew disputes; a blanket minimum of 1 second, which idles sub-second-window clients up to 40× longer than necessary; omitting the header, which violates F3.
ADR-7 — Hash key versions are retained for the retention period of the data they cover. Context: a keyed hash protects key ids, but rotation with immediate destruction of the old key would orphan throttling history, making erasure and subject-access requests unanswerable; the key-to-person mapping lives in the auth system rather than the limiter. Decision: hashed ids carry a version prefix; rotation adds a version without destroying the old one; an old version is destroyed only after the retention period of the events it covers has elapsed. Status: Accepted, pending privacy review (R5). Consequences: erasure is executable by hashing the subject's known keys under each retained version and deleting matching limit events, with the erasure recorded in the audit trail; the cost is that more than one hash key is live at a time, so the hash key material is itself a scoped, versioned secret with its own rotation and destruction audit. Alternatives rejected: single-key rotation with immediate destruction of the old key, which silently makes past events unattributable; and storing raw key ids, which violates G1.
Want one for your own system? Access is by approval, free during beta.
Join the waitlist