Design complete systems / CHAPTER 56
Design an API rate limiter
Apply one account’s burst and refill budget across API servers, and define what callers do when the shared check fails.
Builds on Token bucket versus leaky bucket, Read and write quorums, Stateful versus stateless architecture.
- Choose a policy that specifies both sustained request rate and permitted bursts.
- Prevent concurrent API requests from spending the same saved tokens by checking and subtracting together.
- Explain retries, regional budgets and a separate limit on work already running.
01Functional requirements
A rate limiter decides whether a request may start before the application spends resources on it. We group requests by verified account and operation, such as account A creating a report. A token represents permission to perform a chosen amount of work.
FR1: Account-operation decisions. Decide whether the account has enough allowance for the requested operation before starting expensive work. Derive one consistent account-operation key on the server; a caller cannot choose a new identity or arbitrary key to escape the limit.
FR2: Permitted bursts. Support a token-bucket policy with a declared burst capacity and gradual refill. A normal request costs one token; selected expensive operations may have a reviewed higher cost.
FR3: Useful denials. Reject requests that lack enough allowance with HTTP
429and documented retry guidance. Denied requests do not subtract tokens; invalid costs are rejected.FR4: Managed policies. Allow authorized operators to install versioned capacity, refill, cost, and failure policies. Apply changes to existing bucket balances explicitly rather than silently treating them as new accounts.
FR5: Unauthenticated protection. Apply a separate unauthenticated/IP-based control to login attempts. An IP address is not a unique person: an office or mobile network may put many callers behind one address.
Out of scope
- Absorbing an unlimited network flood; protect the network edge separately.
- Billing and deduplication of business effects. Protective token accounting does not prove that a report, payment, or other application operation happened only once.
Example. Rate and concurrency are different controls. A caller sending 20 requests/second can still exhaust workers if each request runs for a minute; a separate concurrency cap limits work already in progress.
02Nonfunctional requirements
These are independently chosen exercise assumptions to validate, not measured production guarantees.
NFR1: Decision latency. At least 95% of measured regional checks must add less than 5 ms, from the gateway call through the completed store decision. Benchmark with the intended replication settings and realistic uneven traffic across keys.
NFR2: Peak decision capacity. Sustain the assumed peak of 100,000 checks/second. Capacity testing must execute the complete refill/compare/consume operation; fitting the bucket records in memory is not sufficient.
NFR3: Burst and refill contract. Use capacity 40 and refill 20 tokens/second, with normal request cost one. An idle full bucket permits 40 immediate requests and then refills gradually; it does not promise exactly 20 admissions in every clock-aligned second.
NFR4: Owner-level accounting. Two concurrent requests must never spend the same stored allowance at the store responsible for that key. Refill, comparison, subtraction and save must happen as one indivisible operation. This does not by itself protect against losing a committed subtraction when switching to a replica that has not received it.
NFR5: Strict global accounting. If the strict-global extension is required, regions together must never be entitled to spend more than the one global budget. For a 40-token budget, grants of 15, 15 and 10 tokens fit; three grants of 40 do not. Do not reissue an old region’s allowance while it might still spend that allowance.
NFR6: Timeout behavior. Every timed-out decision must retain an uncertain outcome or invoke that endpoint’s reviewed fallback. A missing response must never silently become permission; expensive exports and login protection can require a stricter fallback than cheap reads.
NFR7: Policy propagation. Assume ordinary approved policy changes reach the regional bucket stores within 30 seconds. Check the installed version on each store. For an urgent reduction, confirm that required stores applied it before treating the new limit as effective.
Accounting scope. The baseline provides account-operation protection at one owning store; regional buckets are not automatically a global contractual quota. Confirm whether the stricter extension is required. Three independent 40-token regional buckets permit a combined burst of 120.
03Back-of-the-envelope Estimates
Size the records and the decision work separately. The records can fit in memory while the code checking them is already too busy.
- Bucket records. Assume one million active account-operation pairs, each using 100 bytes.
1,000,000 × 100 = 100 MBof raw state. Add record encoding, expiry data, policy records, extra copies and memory-allocation overhead. - Safe expiry. An empty bucket with capacity 40 and refill 20 tokens/second becomes full after
40 ÷ 20 = 2 secondswithout spending. Delete an inactive bucket only when treating it as full would match its real balance. Deleting an active partially empty bucket earlier would give the caller extra credit. - Network payload. At 100,000 checks/second and 200 bytes per request plus response, payload is
100,000 × 200 = 20 MB/second, before protocol overhead. Measure the complete refill-and-spend call, not only a simple key lookup. - Decision machines. Suppose testing shows one bucket-store machine handles 25,000 complete checks/second. Four provide 100,000/second with no spare. Five have the same nominal total after one loss, but only if current records can be recovered and moved safely. That arithmetic does not itself implement failover.
- An exact history alternative. Recording 100 accepted timestamps for each of one million keys means 100 million entries. At eight bytes per timestamp, timestamps alone use 800 MB. A token bucket stores a balance and time instead, because it implements a different burst-and-refill policy.
04API Design and Retry Contracts
The API gateway is the server that first checks the caller’s identity and maps the route to an operation such as report creation. It asks the limiter whether to start that work. The caller cannot choose its own account or token cost.
POST /internal/rate-decisions
Content-Type: application/json
{"verifiedAccount":"A","operation":"report.create","cost":5,"policyVersion":7}
{"allowed":true,"remaining":35,"retryAfterSeconds":0,"policyVersion":7}
- Build the key. The gateway combines verified account
Awith the standard operation namereport.create. Requests using equivalent routes must reach the same account-operation bucket. Otherwise changing the URL could create a fresh allowance. - Set the cost. The installed policy charges five tokens for this report. With a full 40-token bucket, the example returns 35 remaining.
policyVersion:7identifies the reviewed configuration used for the decision, not a client-selected exemption. - Run one decision. The internal
checkAndConsume(verifiedAccount, operation, cost, policyVersion)call returns whether allowed, the remaining balance and retry guidance. Only an allowed result starts the report. The report’s final response is a separate outcome. - Explain denial. The public response is 429 when the bucket lacks tokens. Denial does not subtract tokens. For a separate one-token example with 0.2 tokens and refill 2/second, the missing 0.8 takes 0.4 seconds; an integer
Retry-Afterof 1 second avoids advising an earlier retry. - Reject invalid costs. A negative cost must not add allowance. Derive allowed costs and keys on trusted code, rather than accepting arbitrary caller fields.
05Data Model and Access Paths
The limiter reads a policy and changes one bucket. The bucket store is the main database responsible for that account-operation balance: every API server must use the same stored balance when making a decision.
| Record / key | Fields | Use |
|---|---|---|
Policy (plan,operation,version) |
Capacity, refill rate, allowed cost, failure rule | Reviewed configuration |
Bucket (account,operation) |
Tokens, lastRefillTime, policyVersion |
Atomic decision state |
Optional operation result (account,operationId) |
Decision and expiry | Deduplicate quota-like retries |
- Policy records tell the gateway and bucket store the capacity, refill rate, cost and outage rule. A version makes a configuration change recognizable.
- Bucket records retain the remaining tokens and the time used for the last refill. Use store-controlled time; if the clock moves backward, earn no negative tokens and do not move the saved timestamp backward.
- Retained operation results are needed when a retry must recover the same accounting decision. Store the account, operation ID, result and expiry for the documented retry period. The report application separately stores its own business result.
- Policy changes must update existing balances deliberately. For example, lowering capacity requires clamping a larger stored balance; it must not silently recreate every customer with a new full bucket.
06Baseline: Make the token decision atomic
First make two API servers share one bucket correctly. A token represents permission to do work. Capacity limits how much permission can accumulate; refill adds permission with elapsed time.
Follow one decision
- The gateway authenticates the account, selects the operation name and assigns its configured cost.
- The bucket store loads that account-operation record and obtains current store-controlled time.
- It computes
newBalance = min(capacity, oldTokens + elapsedSeconds × refillRate). The minimum prevents idle time from accumulating unlimited credit. - It compares the balance with the cost. If enough tokens exist, it subtracts the cost; otherwise it denies without subtracting.
- It saves the balance and refill time, then returns the decision. Steps 2–5 execute as one indivisible store operation, called an atomic operation. No other request for this key may intervene after the check but before subtraction.
- The gateway starts application work only after an allowed response. It returns
429for a known denial.
For a smaller arithmetic example, start with one token, capacity 10 and refill 2/second. After three seconds the store computes min(10,1 +3×2) =7. Five simultaneous one-token calls take turns through the complete operation and leave two. If each server separately read seven and saved its own subtraction, several could spend the same balance.
A database transaction or store-side script can provide the atomic operation. Copying changes to another machine is a separate question: if a replacement has not received a subtraction, failover can restore spent tokens. If the quota must never restore acknowledged tokens, recover every acknowledged subtraction before using the replacement and prevent the old writer from accepting more decisions. Otherwise pause those checks or explicitly state that failover can restore some spent allowance; an atomic script alone cannot provide that recovery guarantee.
| Alternative | Concrete rule | Main consequence |
|---|---|---|
| Fixed window | Count accepted requests in each clock interval | Ten before a minute boundary and ten after may all pass |
| Sliding log | Retain accepted timestamps inside the last interval | Exact rolling count, with more stored history |
| Sliding counter | Estimate the rolling count from neighboring windows | Smaller state, approximate rolling behavior |
| Token bucket | Refill a bounded balance, then spend by request cost | Explicit bursts and sustained refill; chosen here |
| Leaky-bucket shaper | Put work in a finite queue and release at a chosen rate | Smoother output, but callers wait or are rejected |
Pick the required rule before the algorithm. These mechanisms do not promise identical admissions.
One atomic token decision
Follow the numbered arrows
- Stored bucket → Refill at t=3load current state
- Refill at t=3 → Request costs 5compare balance
- Request costs 5 → Commit tokens = 2subtract five once
- Commit tokens = 2 → Allowedreturn committed decision
Trace the diagram in words
- Subtract the saved refill time from current store-controlled time, then refill the balance to seven.
- The bucket store compares the cost and subtracts it while excluding other decisions for that same account-operation key.
- Persist the resulting balance before returning the decision.
BUILD THE DESIGN, ONE CHANGE AT A TIME
Watch the architecture evolve.
Advance through the stages in this same diagram. Existing components keep their places; additions fill the available space, then add rows. Highlights show what changed from the previous stage, and numbered arrows match the connection key below.
Stage 1 of 3
Start here. First prove that two API servers cannot both spend account A’s final token.
Atomic bucket
Follow the numbered arrows
- API caller → API gatewayauthenticated request
- API gateway → Bucket authoritycheckAndConsume account-operation
- Bucket authority → Admission resultcommitted policy decision
- Admission result → API callerdenied:429 + retry advice
- Admission result → Protected applicationallowed:start work
Trace the diagram in words
- The gateway sends account A’s configured report cost to the shared bucket store.
- The store checks and spends from one saved balance atomically.
- A known denial returns 429; an allow starts application work, and a timeout remains uncertain.
- The gateway derives the account, operation and cost from verified identity and installed policy.
- The bucket store refills, compares, subtracts and saves in one indivisible operation.
- The gateway starts report work only after an allowed response.
The cost: One store makes the shared decision clear, but capacity, outage behavior and durable recovery still need testing.
Why change it? Many accounts exceed one store’s tested decision capacity, while slow exports also occupy a finite worker pool.
Regional safety hierarchy
Follow the numbered arrows
- API gateway → Bucket authoritycheckAndConsume account-operation
- Bucket authority → Admission resultcommitted policy decision
- Admission result → API callerdenied:429 + retry advice
- API caller → Local safety guardfirst bounded safety check
- Local safety guard → API gatewaynot the global allowance
- Reviewed policy versions → API gatewayverified policy/cost mapping
- Reviewed policy versions → Bucket authorityinstall reviewed version
- Admission result → Concurrency admissionallowed account spend
- Concurrency admission → Protected applicationworker capacity available
Trace the diagram in words
- The local guard rejects an obvious flood within its small allowance.
- The gateway sends the verified account-operation key to its bucket owner.
- After token admission, the concurrency check starts work only when worker capacity remains.
- Route every account-operation key to one responsible bucket-store partition.
- Reject floods cheaply with a small local guard, and separately cap expensive jobs already running.
- Publish reviewed policy versions and confirm required installations.
Inspect the connection changes
- Now: API caller → Local safety guard: first bounded safety check
- Now: Local safety guard → API gateway: not the global allowance
- Now: Reviewed policy versions → API gateway: verified policy/cost mapping
- Now: Reviewed policy versions → Bucket authority: install reviewed version
- Now: Admission result → Concurrency admission: allowed account spend
- Now: Concurrency admission → Protected application: worker capacity available
- Previously: API caller → API gateway: authenticated request
- Previously: Admission result → Protected application: allowed:start work
All other connections are retained. Response paths are implicit unless drawn.
The cost: More stores add routing and recovery work. Independent local guards must not be advertised as one shared customer allowance.
Why change it? A strict allowance across regions is now required; three independent 40-token buckets would permit 120 immediate requests.
Bounded global leases
Follow the numbered arrows
- API gateway → Bucket authoritycheckAndConsume account-operation
- Bucket authority → Admission resultcommitted policy decision
- Admission result → API callerdenied:429 + retry advice
- API caller → Local safety guardfirst bounded safety check
- Local safety guard → API gatewaynot the global allowance
- Reviewed policy versions → API gatewayverified policy/cost mapping
- Reviewed policy versions → Bucket authorityinstall reviewed version
- Admission result → Concurrency admissionallowed account spend
- Concurrency admission → Protected applicationworker capacity available
- Reviewed policy versions → Global lease authorityreview global budget and lease rules
- Global lease authority → Bucket authoritybounded regional allowance grant
Trace the diagram in words
- The global service divides one budget into recorded regional portions.
- A regional store checks and spends only within its valid portion.
- If a region’s remaining credit is uncertain, keep it reserved. Issue replacement credit only after the old holder cannot spend that same credit.
- A global service records a limited credit grant for each region.
- The regional bucket may spend only its recorded reserved credit.
- Replacement grants wait until old credit can no longer be spent, preventing duplicated rights.
Inspect the connection changes
- Now: Reviewed policy versions → Global lease authority: review global budget and lease rules
- Now: Global lease authority → Bucket authority: bounded regional allowance grant
All other connections are retained. Response paths are implicit unless drawn.
The cost: A quiet or unreachable region can leave reserved credit unused elsewhere. Keep uncertain credit reserved rather than issue it twice; if that loss of capacity is unacceptable, choose one global checker or document a weaker approximate limit.
07Evolution: Add a hierarchy without hiding its approximation
First test a larger bucket-store machine if CPU or network is the limit. Extra memory does not make one very busy key’s decisions independent.
Spread many different accounts
- Divide bucket records among store machines. A hash of the verified account-operation key selects its owner, so all servers send that key’s decisions to the same machine.
- Keep API servers free of the shared balance. Adding an API process must not create a new full allowance.
- Add cheap local flood protection before the shared check, and a separate limit on expensive jobs already running. These protect different resources. Fifty local processes each granting 40 tokens could admit 2,000 requests; they are not a single 40-token quota.
- Distribute reviewed policy versions and measure installation. Ordinary changes must reach owners within the 30-second target; urgent reductions need confirmation that each required owner applied them.
If the product needs one budget across regions
- Three independent 40-token buckets permit 120 immediate requests. Choose one global decision service if every request must use one budget and the extra network delay is acceptable.
- Alternatively, let a global service reserve limited portions for regions. For example, grants of 15, 15 and 10 divide a 40-token budget. A region may spend only its reserved portion. These grants are called token leases.
- The global service records each grant ID, its region and reserved credit. If a region fails and its remaining credit is unknown, leave that credit reserved; do not give it to a replacement. Reuse it only after the old region can no longer spend it. A local timeout or expiry entry alone does not stop the old region’s store from accepting requests.
- Preserve the same accounting limits for newly refilled credit. If that protocol cannot be shown, use one authority or state the approximation instead of calling independent buckets a strict global quota.
| Change | Benefit | Added responsibility |
|---|---|---|
| Partition by account-operation | Share work across many account keys | Correct routing and recovery of each balance |
| Local safety guard | Reject obvious floods cheaply | Keep its independent allowance small and explicit |
| Concurrency cap | Protect occupied export workers | Track running work separately from arrival rate |
| Global grants | Avoid a global call on each regional request | Safe grant accounting, recovery and unused capacity |
08Failure and Recovery Scenarios
Use request R7, account A’s report costing five tokens, to test each boundary:
- Known allow or deny. The gateway sends
R7’s account-operation check to the bucket owner. An allowed result starts the report; a denied result returns429without starting it. - Lost limiter reply. The store may have subtracted tokens before its reply disappeared. A repeated protective check may spend twice conservatively. If strict accounting requires one spend, the store saves
R7’s operation ID and decision with the subtraction, then returns that saved decision on retry. - Lost report reply. Spending a token does not prove one report was created. The report service needs its own stable request ID and saved report result, so a repeated application request recovers that report.
- Unavailable bucket store. Apply the endpoint’s reviewed policy: a cheap public read may get a small short-lived local allowance; an expensive export may reject; login protection may require severe fallback throttling. Never convert a missing response into unlimited permission.
- Store failover. Check whether the replacement contains acknowledged spending before routing decisions to it. A script that was atomic on the failed machine cannot restore missing state on another machine.
- Recovery burst. Give clients retry guidance, cap waiting calls and vary retry delays randomly. This jitter prevents all rejected clients from returning together at the same instant.
09Trade-offs and Validation
Explain the shared balance and the resource it protects before adding more limiters.
- Measure the decision: check latency, allowed and denied counts, installed policy versions and calls concentrated on one key.
- Measure dependency failures: separate store errors, emergency fallback use and work delayed by failover from ordinary quota denials.
- Measure actual protection: watch running exports and downstream saturation, not only accepted calls/second. At 20 requests/second lasting a minute, about 1,200 jobs can occupy workers.
- Protect administration: authorize policy edits, audit changes and limit configuration requests. A bad reduction can reject healthy customers just like an outage.
- Protect tenant data: do not expose one account’s usage through another account’s responses or metric labels.
Close with the small example: three seconds refill the balance to seven, five requests leave two, and a lost reply makes the accounting outcome uncertain. State which record resolves that uncertainty and whether the regional limit is approximate or globally coordinated.
OPTIONAL DESIGN EXTENSIONWhere AI Fits in This Design
A model may optionally help operators detect unusual traffic. It does not decide whether a request can spend tokens.
- Input and output. A background analysis program sends minimized account-operation rates, error ratios and burst sizes. The model returns a risk score and a suggested policy change for review, not new allowance.
- Integration. Authorized policy code validates an approved change and publishes a version. The gateway and bucket store still use that version, verified identity, configured cost and saved token balance for each decision.
- Fallback and latency. Keep inference outside the required 5 ms limiter call. If the model is slow, unavailable or uncalibrated, continue the installed policy and the endpoint’s existing outage rule. A “safe” score cannot refill an empty bucket.
- Evaluation and cost. Measure legitimate users incorrectly flagged, time to detect harmful traffic, differences across account classes and downstream overload. Budget aggregate collection, model calls and storage retention.
- Privacy. Exclude secrets and unnecessary request bodies. Restrict access to tenant activity and proposed changes. Cloudflare’s ML attack scoring illustrates detection metadata; it does not provide shared token accounting.
Strict spending and business results remain explicit stored decisions.
DRAW. CALCULATE. EXPLAIN.
Race two API servers against a shared bucket
75 minutes · guided practice
Start with capacity 10, refill 2/second and zero tokens at t=0. At t=3, six one-token requests arrive together across two API servers. Show every allowed decision. Then make the store unavailable and design different fallbacks for a public product read and a costly account export. Explain how the answer changes with three independent regions.
Compare the worked solution
- Refill the shared record. The bucket starts empty. At t=3, the store computes
0 +3×2 =6tokens, below capacity 10. - Take turns through the decision. Six one-token checks leave balances 5, 4, 3, 2, 1 and 0. All six pass. A seventh immediate check would be denied. Two API servers must use this same record and atomic check, not two private balances.
- Choose bounded outage behavior. Give the cheap read a specifically sized, short-lived local emergency allowance if the product permits it. Reject the expensive export or use a much smaller reviewed emergency budget. “Allow locally” without a number or lifetime does not bound overload.
- Compare regions. Three independently full buckets multiply the burst. A strict global total needs one checking service or recorded regional grants whose combined valid credit stays within the global budget. Do not issue replacement credit while the old holder may spend it.
- Recover retries at both layers. A strict quota stores the operation ID and decision with its spend. The export service separately stores the export result. Retrying after a lost reply must recover the appropriate saved record, not infer failure from the timeout.
- State the guarantee. Capacity 10 and refill 2/second permit a burst with gradual recovery, not exactly ten requests per calendar window. The store’s atomic operation also needs the stated durable failover policy so acknowledged spending is not forgotten.
The checkboxes record your own assessment. The worked solution gives you a way to check the reasoning.
RETRIEVE BEFORE YOU REVEAL
Keep the important ideas.
Say the answer aloud, then turn the card over.
CARD 01What exactly does a token bucket promise?
The gateway identifies the account and operation. The shared store refills its saved balance, checks the cost and subtracts in one atomic operation. Capacity bounds an immediate burst; elapsed time restores tokens at the refill rate.
CARD 02How do the five rate-limit mechanisms differ?
A fixed window counts within clock intervals. A sliding log keeps recent timestamps; a sliding counter estimates that history. A token bucket spends refillable credit. A leaky shaper instead queues requests and releases them gradually. Choose the desired rule first.
CARD 03Why are local and global limits different?
Independent full regional buckets multiply the total allowance. Use one global checker or recorded portions of one budget. If a failed region may still spend its credit, leave that credit reserved instead of giving it to a replacement. Separately cap running jobs so long requests cannot occupy every worker.
CHECK THE REASONING
Can you explain the choice?
Answer every question, then finish for an immediate score and an explanation for each answer. Aim for 80%, then revisit any gaps.
CLOSE THE LOOP
Explain it without the page.
Summarise the mechanism, one trade-off, and one failure mode. Then mark this chapter complete.