Markdown Editor

Rate Limiter System Design Template (Reference Design)

Distributed rate limiter: token bucket vs window algorithms, atomic Redis scripts, multi-region shares, 429 headers, fail-open

Як використовувати: Use when protecting APIs or enforcing quotas. Fill the policy table with real tiers before sizing Redis. The main pitfall is deciding fail-open versus fail-closed only after an outage; set it per policy class up front.

Перегляд

System Design: [Rate limiter name]

Reference design for a distributed rate limiter that protects APIs from overload and abuse and enforces per-customer quotas. It compares token bucket, leaky bucket, fixed window and sliding window algorithms, specifies a Redis-based implementation with atomic scripts, discusses multi-region limits, response headers (Retry-After and the IETF RateLimit header fields draft) and the fail-open versus fail-closed decision. It follows the structure of the System Design Document template and is pre-filled with a worked design to adapt. Values marked Example are assumptions.

Document control

FieldValue
Document ID[SDD-RL-001]
System / feature[API rate limiting]
Version[0.1]
Status[Draft / In review / Approved / Implemented / Superseded]
Author(s)[@author]
Reviewers[@reviewer-1], [@reviewer-2]
Last updatedYYYY-MM-DD
Related[API gateway design], [Pricing tiers], [ADR: fail-open policy]

Approvals

RoleNameDecisionDate
Tech lead[@name][Approved / Changes requested]YYYY-MM-DD
Staff / principal engineer[@name][Decision]YYYY-MM-DD
SRE / operations[@name][Decision]YYYY-MM-DD
Security[@name][Decision]YYYY-MM-DD
Product (quota tiers)[@name][Decision]YYYY-MM-DD

Revision history

VersionDateAuthorChange summary
0.1YYYY-MM-DD[@name]First draft for design review
0.2YYYY-MM-DD[@name]Example: Switched from fixed window to token bucket after burst analysis
0.3YYYY-MM-DD[@name]Example: Added local pre-check layer to cut Redis calls for abusive keys
1.0YYYY-MM-DD[@name]Example: Approved

Contents

1. Overview

1.1 Problem statement

[Example: Each API instance limits clients in local memory, so a client spread across 40 instances gets 40 times its quota. A single customer's batch job caused a 25-minute brownout last month, and paid tiers cannot be enforced reliably.]

1.2 Proposed solution

[Example: The API gateway calls a rate-limit module on every request. The module identifies the client (API key, user, or IP for anonymous traffic), looks up the policy, and runs an atomic token-bucket script in a sharded Redis cluster in the same region. Rejected requests get HTTP 429 with Retry-After and RateLimit headers. A small in-process cache blocks keys that are already exhausted without calling Redis. If Redis is unavailable, the gateway fails open for quota limits and falls back to coarse local limits for protection.]

1.3 Decisions requested from reviewers

IDQuestionRecommended optionSection
Q-01Example: Which algorithm?Token bucket for API quotas; sliding window counter for login attempts7.2, 10
Q-02Example: Global or per-region limits?Per-region share of the global limit, rebalanced every 10 s7.5
Q-03Example: Behaviour when Redis is down?Fail open with local fallback limits; fail closed for login7.6
Q-04Example: Where does the limiter run?In the gateway process, not a separate network hop6, 10
[Q-nn][Question][Option][Section]

2. Requirements

2.1 Functional requirements

IDRequirementPriority
FR-01Example: Limit requests per API key per policy (rate and burst)Must
FR-02Example: Different limits per plan tier and per endpoint groupMust
FR-03Example: Limit anonymous traffic per IP or /64 IPv6 prefixMust
FR-04Example: Return 429 with Retry-After and RateLimit headersMust
FR-05Example: Change policies without redeploying (effective within 60 s)Must
FR-06Example: Support weighted cost (an export costs 10 units)Should
FR-07Example: Shadow mode: evaluate and log without rejectingShould
[FR-nn][Requirement][Must / Should / Could]

2.2 Non-functional requirements

IDQualityTargetMeasured as
NFR-01Added latencyExample: p99 at or below 2 ms per requestGateway timing
NFR-02AccuracyExample: Within 5% of the configured limit in one region; within 15% globallyLoad test with synthetic clients
NFR-03AvailabilityExample: Limiter failure never makes the API unavailableChaos test with Redis down
NFR-04ThroughputExample: 1 million decisions/s at peakLoad test
NFR-05IsolationExample: One abusive key cannot raise latency for othersHot-key test
[NFR-nn][Quality][Target][Measurement]

2.3 Out of scope

  • [Example: billing for overage usage (separate metering pipeline)]
  • [Example: volumetric DDoS protection at layers 3 and 4 (handled by the edge provider)]
  • [Example: concurrency limits on long-running jobs]
  • [Item deferred, with the release that will take it]

3. Capacity estimation

3.1 Assumptions

InputSymbolValueSource
Peak API requests per second (all regions)RRExample: 1 000 000Gateway metrics x growth
RegionsnnExample: 3Deployment plan
Distinct active keys per hour (API keys + IPs)KKExample: 20 millionGateway logs
Policies evaluated per requestppExample: 2 (key-wide and endpoint group)7.1
Memory per bucket in Redis incl. overheadssExample: 120 bytesMeasured with a hash of 2 fields
Script executions per Redis primary per secondccExample: 80 000Benchmark on target instance type

3.2 Redis operations

Without a local cache every policy check is one script call:

O=R×p=106×2=2×106 script calls/sO = R \times p = 10^{6} \times 2 = 2 \times 10^{6} \ \text{script calls/s}

The local deny cache (7.4) and batching of the two policies into one script call reduce this to one call per request, about 10610^{6}/s globally, or per region:

Oregion=1063≈333 000 calls/s,Nprimaries=Oregionc×1.5=333 00080 000×1.5≈6.3⇒8 primaries per regionO_{region} = \frac{10^{6}}{3} \approx 333\,000 \ \text{calls/s}, \qquad N_{primaries} = \frac{O_{region}}{c} \times 1.5 = \frac{333\,000}{80\,000} \times 1.5 \approx 6.3 \Rightarrow 8 \ \text{primaries per region}

The factor 1.5 keeps headroom for uneven slot distribution; 8 is chosen as the next power of two for even resharding.

3.3 Memory

M=K×p×s=2×107×2×120 B=4.8 GB per regionM = K \times p \times s = 2 \times 10^{7} \times 2 \times 120 \ \text{B} = 4.8 \ \text{GB per region}

Keys expire when idle (TTL equal to the time to refill the bucket plus one second), so memory follows active keys, not all keys ever seen. With one replica per primary the cluster holds about 9.6 GB per region.

3.4 Latency

A same-zone Redis round trip is typically 0.2 to 0.5 ms; the script itself runs in microseconds. With a 2 ms budget there is room for one round trip and a timeout of 5 ms after which the request falls back (7.6).

3.5 Summary

QuantityEstimateDesign consequence
Decisions1 million/s globallyOne script call per request; local deny cache
Redis primaries8 per regionRedis Cluster, hash slot by limiter key
Memory~5 GB per region (~10 GB with replicas)Small instances; TTL on all keys
LatencyUnder 1 ms typicalSame-zone placement; strict timeouts

4. API design

4.1 Interfaces

The limiter is an in-process library in the gateway plus an admin API for policies.

InterfaceOperationPurpose
Librarycheck(identity, route, cost) -> DecisionDecide allow or reject; returns remaining, reset and retry values
Admin APIPUT /v1/policies/{id}Create or update a policy (versioned)
Admin APIGET /v1/policies?tier=List policies
Admin APIPOST /v1/overridesTemporary per-key override with expiry
Admin APIGET /v1/keys/{key}/usageCurrent bucket state for support

4.2 Response headers

Every response carries the current quota state; a rejected request also gets Retry-After (RFC 9110) with status 429 (RFC 6585). The RateLimit fields below follow the structured-field syntax of the IETF httpapi working group draft; the syntax has changed between draft revisions, so pin the revision you implement and say so in your API documentation.

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 3
RateLimit-Policy: "burst";q=100;w=1, "daily";q=50000;w=86400
RateLimit: "burst";r=0;t=3

{"type": "https://api.example.com/problems/rate-limited", "title": "Rate limit exceeded", "status": 429, "detail": "Policy 'burst' allows 100 requests per second for this key.", "instance": "/v1/orders"}

4.3 Policy format

Policies are versioned JSON documents managed through the admin API; a new version starts in shadow mode unless the reviewer sets enforce:

{"id": "pro-default", "version": 7, "match": {"tier": "pro", "routes": ["/v1/*"]}, "algorithm": "token_bucket", "rate_per_sec": 100, "burst": 200, "cost": {"POST /v1/exports": 10}, "on_store_failure": "open", "mode": "enforce"}

5. Data model

5.1 Entities

Policies and overrides are configuration; buckets are short-lived counters.

5.2 Storage choices

DataStoreKeyMain queriesWhy
BucketsExample: Redis Cluster per regionrl:{policy_id}:{subject} with hash tag on subjectAtomic read-modify-writeSub-millisecond, scripts run atomically
Policies, overridesRelational database, pushed to gatewayspolicy_id, versionAll active policiesSmall, audited, versioned
Decisions logSampled to the event pipelineTimeAnalysis, shadow mode resultsNever on the request path

Keys use a hash tag on the subject (rl:{key_8f2a}:burst) so all policies for one subject land on the same slot and can be checked in one script call.

6. High-level design

The decision is made inside the gateway; only the bucket state is remote.

ComponentResponsibilityScales by
Identify subjectDerive the limiter subject from API key, user or IP prefixWith gateway
Policy cacheHold active policies in memory; refresh on push or every 30 sWith gateway
Local deny cacheRemember keys rejected until their retry timeWith gateway
Limiter clientRun atomic script, enforce timeout, apply fallbackWith gateway
Redis ClusterStore bucketsPrimaries per region
Config distributorPush policy versions to gatewaysSingle service, cached

7. Detailed design

7.1 Policies

TierPolicyRateBurstWindow behaviour
Example: FreePer key, all routes10/s20Token bucket
Example: FreePer key, daily10 000/daynoneFixed window, UTC day
Example: ProPer key, all routes100/s200Token bucket
Example: AnonymousPer IP or /64 prefix5/s10Token bucket
Example: LoginPer account and per IP5/15 minnoneSliding window counter, fail closed

7.2 Algorithms compared

AlgorithmState per keyBurstsAccuracyBest for
Token bucketTokens + last refill timeAllows up to bucket sizeExact for rate and burstAPI quotas with bursty clients
Leaky bucket (queue)Queue or level + timeSmooths to constant outputExact output rateShaping traffic to a fragile backend
Fixed window counterOne counter per windowUp to 2x at window edgesLow at edgesDaily or monthly quotas
Sliding window logTimestamp per requestNone beyond limitExactLow limits (login), small volumes
Sliding window counterTwo countersSmall, approximatedGood (assumes even spread in previous window)High-volume per-minute limits

Token bucket, with refill rate rr tokens per second and capacity bb:

tokensnow=min⁡(b, tokensprev+r⋅Δt),allow if tokensnow≥costtokens_{now} = \min\left(b,\ tokens_{prev} + r \cdot \Delta t\right), \qquad \text{allow if } tokens_{now} \ge cost

Sliding window counter estimate for window length ww and elapsed time ee in the current window:

n^=ncurr+nprev×(1−ew)\hat{n} = n_{curr} + n_{prev} \times \left(1 - \frac{e}{w}\right)

For example with w=60w = 60 s, e=15e = 15 s, nprev=80n_{prev} = 80, ncurr=30n_{curr} = 30: n^=30+80×0.75=90\hat{n} = 30 + 80 \times 0.75 = 90, so a limit of 100 allows 10 more.

7.3 Atomic Redis script

Read, refill, decide and write must be one atomic step, otherwise two gateways can both read 1 token and both allow. The script uses Redis server time so gateway clock skew cannot mint tokens.

-- KEYS[1] = bucket key; ARGV = rate_per_sec, burst, cost
local rate  = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local cost  = tonumber(ARGV[3])
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or burst
local ts = tonumber(data[2]) or now
tokens = math.min(burst, tokens + math.max(0, now - ts) / 1000 * rate)
local allowed, retry_ms = 0, 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
else
  retry_ms = math.ceil((cost - tokens) / rate * 1000)
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(burst / rate * 1000) + 1000)
return {allowed, math.floor(tokens), retry_ms}

The sequence for one request on the critical path:

Retry-After is in whole seconds, so 1 200 ms rounds up to 2.

7.4 Local deny cache and hot keys

A client hammering after rejection would otherwise cost one Redis call per request and concentrate load on one slot. The gateway remembers rejected keys until their retry time and rejects locally. Over-admission is bounded: a key that becomes allowed again is checked in Redis on its next request.

7.5 Multi-region and consistency model

  • Per region, strongly consistent: each region's Redis is the single source of truth for its buckets; the script is atomic, so within a region the limit is exact (NFR-02).
  • Globally, approximate: each region enforces a share of the global limit, initially Lglobal/nL_{global}/n. Every 10 s regions publish observed usage per key to the others and shares are rebalanced toward where traffic is. A client that moves all traffic to one region may be under-served for up to one rebalance interval; one that spreads evenly gets the full limit.
  • Daily quotas use fixed-window counters replicated asynchronously; the worst-case overshoot is the traffic served during replication lag, which is accepted for non-billing quotas. Billing-grade quotas are reconciled from metering, not from the limiter.

7.6 Fail-open versus fail-closed

Policy classOn Redis error or timeoutRationale
Quota (per key)Fail open, then apply a coarse local limit of rate x 2 per gateway instanceAvailability of paid API matters more than exact quotas for minutes
Capacity protection (global)Local limits per instance, sized as global / instance countBackend must still be protected
Security (login, OTP, password reset)Fail closed with clear errorBrute force during an outage is worse than blocked logins

A circuit breaker opens after 50% errors over 10 s so failing calls stop adding latency; it probes again after 5 s.

8. Scalability and performance

ConcernApproachLimit before redesign
Decision rateOne script call per request, sharded clusterExample: ~640 000 calls/s per region with 8 primaries
Hot keysLocal deny cache; per-key rate itself caps callsOne allowed key at its own limit
MemoryTTL on every bucketTens of GB; well within instance sizes
Policy churnVersioned push; gateways keep last good version[Limit]
Many policies per requestSingle script evaluates all policies for a subjectAbout 5 policies per request

Latency budget for the limiter step (target p99 2 ms):

StepBudget
Identify subject and select policies (in memory)0.1 ms
Local deny cache lookup0.05 ms
Redis round trip and script1.0 ms
Header formatting0.05 ms
Headroom0.8 ms

9. Reliability, security and operations

9.1 Failure modes

FailureImpactDetectionMitigation
Redis primary failoverBuckets on that shard reset or briefly unavailableError rate per shardFail-open policy; reset buckets start full, so at most one burst extra
Redis cluster downExact limits unavailableCircuit breaker openLocal fallback limits per 7.6
Bad policy pushedLegitimate traffic rejected429 rate spike by tierShadow mode for new policies; one-click rollback to previous version
Clock skew between gatewaysIncorrect refillNot applicableScript uses Redis server time
Identification bypass (rotating IPs)Anonymous limits ineffectiveUnique IP counts per routePrefix-based keys; edge bot management
[Failure][Impact][Signal][Mitigation]

9.2 Security and abuse

ThreatControl
Spoofed client IP headersTrust X-Forwarded-For only from known proxies; use the edge-provided client IP
Key sharing to multiply quotaLimits per account in addition to per key
Enumeration of limits to time attacksLogin limits not exposed in headers
Policy tamperingAdmin API requires privileged role, changes audited and reviewed
[Threat][Control]

9.3 Observability

SignalMetric or logAlert threshold
Limiter latencyp99 per gatewayAbove 2 ms for 10 min
Rejection rate429 share by tier and routeDoubling vs. 7-day baseline
Fallback modeRequests decided without RedisAny for more than 1 min
Redis CPUPer primaryAbove 70%
Top limited keysSampled decision eventsDashboard for support

9.4 Cost estimate

ItemUnit costQuantityMonthly cost
Redis primaries and replicas[$/node-month]Example: 16 per region x 3[$]
Policy service and database[$/month]1 small deployment[$]
Decision event sampling (1%)[$/GB ingested][GB][$]
Total[$]

10. Trade-offs and alternatives

DecisionOptionsChosenWhyWhat we give up
Algorithm for API quotasFixed window; sliding log; sliding counter; token bucketToken bucketExplicit burst and rate, O(1) stateLess intuitive than "N per minute" for customers
PlacementSeparate limiter service; in-gateway library; sidecarIn-gateway libraryNo extra network hopLibrary updates require gateway deploys
State storeLocal memory only; central database; RedisRedis with scriptsAtomic, fast, shared across instancesAnother stateful system to run
Global limitsSingle global store; per-region sharesPer-region sharesNo cross-region latency on requestsApproximate global accuracy
Store failureAlways fail closed; always fail open; per classPer policy classBalances availability and securityMore configuration to review
[Decision][Options][Chosen][Why][Cost]

11. Delivery plan and risks

11.1 Milestones

MilestoneScopeExit criterionTarget date
M1Library, Redis cluster, shadow mode in one regionShadow decisions match expected limits within 5%YYYY-MM-DD
M2Enforce for anonymous and free tierNo unexpected 429 complaints for 14 daysYYYY-MM-DD
M3All tiers, headers documentedChaos test with Redis down passesYYYY-MM-DD
M4Multi-region share rebalancingGlobal accuracy within 15% in testYYYY-MM-DD

11.2 Rollout and rollback

[Example: Every new policy starts in shadow mode for 7 days. Enforcement is enabled per tier with a feature flag; rollback sets the policy mode back to shadow, which takes effect within 60 s.]

11.3 Risks

IDRiskLikelihoodImpactMitigationOwner
R-01Example: Customers with legitimate bursts are throttledMediumHighShadow data review; burst sized from p99 usage[@name]
R-02Example: Limiter adds latency during Redis incidentsMediumMedium5 ms timeout and circuit breaker[@name]
R-03Example: RateLimit header draft changesHighLowVersion the header behaviour; keep Retry-After as the stable signal[@name]
[R-nn][Risk][L / M / H][L / M / H][Mitigation][@name]

12. Appendices

12.1 Open questions

IDQuestionOwnerDue
OQ-01Example: Do enterprise customers get per-region dedicated limits?[@name]YYYY-MM-DD
OQ-02Example: Should GraphQL requests be costed by query complexity?[@name]YYYY-MM-DD
[OQ-nn][Question][@name]YYYY-MM-DD

12.2 Glossary

TermMeaning
SubjectThe identity a limit applies to: API key, account, user or IP prefix
BurstMaximum tokens a bucket holds, so the largest instant spike allowed
Fail openAllow requests when the limiter cannot decide
Fail closedReject requests when the limiter cannot decide
Shadow modeEvaluate and log decisions without enforcing them
[Term][Meaning]

12.3 References

ReferenceUse
RFC 6585 Additional HTTP Status CodesStatus 429
RFC 9110 HTTP SemanticsRetry-After field
IETF httpapi draft "RateLimit header fields for HTTP"RateLimit and RateLimit-Policy fields (draft, revision to be pinned)
RFC 9457 Problem Details for HTTP APIsError body
Redis documentation on scripting and Redis Cluster hash tags7.3 and 5.2

Standard and licence note

Based on common industry practice for distributed rate limiting and on the HTTP specifications cited in 12.3; the RateLimit header fields are an IETF draft, not a published RFC. This is a reference design to adapt, with illustrative numbers. Original text written for this collection. Template licence: MIT.

Схожі шаблони