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-Afterand 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
| Field | Value |
|---|---|
| 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 updated | YYYY-MM-DD |
| Related | [API gateway design], [Pricing tiers], [ADR: fail-open policy] |
Approvals
| Role | Name | Decision | Date |
|---|---|---|---|
| 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
| Version | Date | Author | Change summary |
|---|---|---|---|
| 0.1 | YYYY-MM-DD | [@name] | First draft for design review |
| 0.2 | YYYY-MM-DD | [@name] | Example: Switched from fixed window to token bucket after burst analysis |
| 0.3 | YYYY-MM-DD | [@name] | Example: Added local pre-check layer to cut Redis calls for abusive keys |
| 1.0 | YYYY-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
| ID | Question | Recommended option | Section |
|---|---|---|---|
| Q-01 | Example: Which algorithm? | Token bucket for API quotas; sliding window counter for login attempts | 7.2, 10 |
| Q-02 | Example: Global or per-region limits? | Per-region share of the global limit, rebalanced every 10 s | 7.5 |
| Q-03 | Example: Behaviour when Redis is down? | Fail open with local fallback limits; fail closed for login | 7.6 |
| Q-04 | Example: Where does the limiter run? | In the gateway process, not a separate network hop | 6, 10 |
| [Q-nn] | [Question] | [Option] | [Section] |
2. Requirements
2.1 Functional requirements
| ID | Requirement | Priority |
|---|---|---|
| FR-01 | Example: Limit requests per API key per policy (rate and burst) | Must |
| FR-02 | Example: Different limits per plan tier and per endpoint group | Must |
| FR-03 | Example: Limit anonymous traffic per IP or /64 IPv6 prefix | Must |
| FR-04 | Example: Return 429 with Retry-After and RateLimit headers | Must |
| FR-05 | Example: Change policies without redeploying (effective within 60 s) | Must |
| FR-06 | Example: Support weighted cost (an export costs 10 units) | Should |
| FR-07 | Example: Shadow mode: evaluate and log without rejecting | Should |
| [FR-nn] | [Requirement] | [Must / Should / Could] |
2.2 Non-functional requirements
| ID | Quality | Target | Measured as |
|---|---|---|---|
| NFR-01 | Added latency | Example: p99 at or below 2 ms per request | Gateway timing |
| NFR-02 | Accuracy | Example: Within 5% of the configured limit in one region; within 15% globally | Load test with synthetic clients |
| NFR-03 | Availability | Example: Limiter failure never makes the API unavailable | Chaos test with Redis down |
| NFR-04 | Throughput | Example: 1 million decisions/s at peak | Load test |
| NFR-05 | Isolation | Example: One abusive key cannot raise latency for others | Hot-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
| Input | Symbol | Value | Source |
|---|---|---|---|
| Peak API requests per second (all regions) | Example: 1 000 000 | Gateway metrics x growth | |
| Regions | Example: 3 | Deployment plan | |
| Distinct active keys per hour (API keys + IPs) | Example: 20 million | Gateway logs | |
| Policies evaluated per request | Example: 2 (key-wide and endpoint group) | 7.1 | |
| Memory per bucket in Redis incl. overhead | Example: 120 bytes | Measured with a hash of 2 fields | |
| Script executions per Redis primary per second | Example: 80 000 | Benchmark on target instance type |
3.2 Redis operations
Without a local cache every policy check is one script call:
The local deny cache (7.4) and batching of the two policies into one script call reduce this to one call per request, about /s globally, or 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
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
| Quantity | Estimate | Design consequence |
|---|---|---|
| Decisions | 1 million/s globally | One script call per request; local deny cache |
| Redis primaries | 8 per region | Redis Cluster, hash slot by limiter key |
| Memory | ~5 GB per region (~10 GB with replicas) | Small instances; TTL on all keys |
| Latency | Under 1 ms typical | Same-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.
| Interface | Operation | Purpose |
|---|---|---|
| Library | check(identity, route, cost) -> Decision | Decide allow or reject; returns remaining, reset and retry values |
| Admin API | PUT /v1/policies/{id} | Create or update a policy (versioned) |
| Admin API | GET /v1/policies?tier= | List policies |
| Admin API | POST /v1/overrides | Temporary per-key override with expiry |
| Admin API | GET /v1/keys/{key}/usage | Current 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
| Data | Store | Key | Main queries | Why |
|---|---|---|---|---|
| Buckets | Example: Redis Cluster per region | rl:{policy_id}:{subject} with hash tag on subject | Atomic read-modify-write | Sub-millisecond, scripts run atomically |
| Policies, overrides | Relational database, pushed to gateways | policy_id, version | All active policies | Small, audited, versioned |
| Decisions log | Sampled to the event pipeline | Time | Analysis, shadow mode results | Never 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.
| Component | Responsibility | Scales by |
|---|---|---|
| Identify subject | Derive the limiter subject from API key, user or IP prefix | With gateway |
| Policy cache | Hold active policies in memory; refresh on push or every 30 s | With gateway |
| Local deny cache | Remember keys rejected until their retry time | With gateway |
| Limiter client | Run atomic script, enforce timeout, apply fallback | With gateway |
| Redis Cluster | Store buckets | Primaries per region |
| Config distributor | Push policy versions to gateways | Single service, cached |
7. Detailed design
7.1 Policies
| Tier | Policy | Rate | Burst | Window behaviour |
|---|---|---|---|---|
| Example: Free | Per key, all routes | 10/s | 20 | Token bucket |
| Example: Free | Per key, daily | 10 000/day | none | Fixed window, UTC day |
| Example: Pro | Per key, all routes | 100/s | 200 | Token bucket |
| Example: Anonymous | Per IP or /64 prefix | 5/s | 10 | Token bucket |
| Example: Login | Per account and per IP | 5/15 min | none | Sliding window counter, fail closed |
7.2 Algorithms compared
| Algorithm | State per key | Bursts | Accuracy | Best for |
|---|---|---|---|---|
| Token bucket | Tokens + last refill time | Allows up to bucket size | Exact for rate and burst | API quotas with bursty clients |
| Leaky bucket (queue) | Queue or level + time | Smooths to constant output | Exact output rate | Shaping traffic to a fragile backend |
| Fixed window counter | One counter per window | Up to 2x at window edges | Low at edges | Daily or monthly quotas |
| Sliding window log | Timestamp per request | None beyond limit | Exact | Low limits (login), small volumes |
| Sliding window counter | Two counters | Small, approximated | Good (assumes even spread in previous window) | High-volume per-minute limits |
Token bucket, with refill rate tokens per second and capacity :
Sliding window counter estimate for window length and elapsed time in the current window:
For example with s, s, , : , 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 . 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 class | On Redis error or timeout | Rationale |
|---|---|---|
| Quota (per key) | Fail open, then apply a coarse local limit of rate x 2 per gateway instance | Availability of paid API matters more than exact quotas for minutes |
| Capacity protection (global) | Local limits per instance, sized as global / instance count | Backend must still be protected |
| Security (login, OTP, password reset) | Fail closed with clear error | Brute 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
| Concern | Approach | Limit before redesign |
|---|---|---|
| Decision rate | One script call per request, sharded cluster | Example: ~640 000 calls/s per region with 8 primaries |
| Hot keys | Local deny cache; per-key rate itself caps calls | One allowed key at its own limit |
| Memory | TTL on every bucket | Tens of GB; well within instance sizes |
| Policy churn | Versioned push; gateways keep last good version | [Limit] |
| Many policies per request | Single script evaluates all policies for a subject | About 5 policies per request |
Latency budget for the limiter step (target p99 2 ms):
| Step | Budget |
|---|---|
| Identify subject and select policies (in memory) | 0.1 ms |
| Local deny cache lookup | 0.05 ms |
| Redis round trip and script | 1.0 ms |
| Header formatting | 0.05 ms |
| Headroom | 0.8 ms |
9. Reliability, security and operations
9.1 Failure modes
| Failure | Impact | Detection | Mitigation |
|---|---|---|---|
| Redis primary failover | Buckets on that shard reset or briefly unavailable | Error rate per shard | Fail-open policy; reset buckets start full, so at most one burst extra |
| Redis cluster down | Exact limits unavailable | Circuit breaker open | Local fallback limits per 7.6 |
| Bad policy pushed | Legitimate traffic rejected | 429 rate spike by tier | Shadow mode for new policies; one-click rollback to previous version |
| Clock skew between gateways | Incorrect refill | Not applicable | Script uses Redis server time |
| Identification bypass (rotating IPs) | Anonymous limits ineffective | Unique IP counts per route | Prefix-based keys; edge bot management |
| [Failure] | [Impact] | [Signal] | [Mitigation] |
9.2 Security and abuse
| Threat | Control |
|---|---|
| Spoofed client IP headers | Trust X-Forwarded-For only from known proxies; use the edge-provided client IP |
| Key sharing to multiply quota | Limits per account in addition to per key |
| Enumeration of limits to time attacks | Login limits not exposed in headers |
| Policy tampering | Admin API requires privileged role, changes audited and reviewed |
| [Threat] | [Control] |
9.3 Observability
| Signal | Metric or log | Alert threshold |
|---|---|---|
| Limiter latency | p99 per gateway | Above 2 ms for 10 min |
| Rejection rate | 429 share by tier and route | Doubling vs. 7-day baseline |
| Fallback mode | Requests decided without Redis | Any for more than 1 min |
| Redis CPU | Per primary | Above 70% |
| Top limited keys | Sampled decision events | Dashboard for support |
9.4 Cost estimate
| Item | Unit cost | Quantity | Monthly 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
| Decision | Options | Chosen | Why | What we give up |
|---|---|---|---|---|
| Algorithm for API quotas | Fixed window; sliding log; sliding counter; token bucket | Token bucket | Explicit burst and rate, O(1) state | Less intuitive than "N per minute" for customers |
| Placement | Separate limiter service; in-gateway library; sidecar | In-gateway library | No extra network hop | Library updates require gateway deploys |
| State store | Local memory only; central database; Redis | Redis with scripts | Atomic, fast, shared across instances | Another stateful system to run |
| Global limits | Single global store; per-region shares | Per-region shares | No cross-region latency on requests | Approximate global accuracy |
| Store failure | Always fail closed; always fail open; per class | Per policy class | Balances availability and security | More configuration to review |
| [Decision] | [Options] | [Chosen] | [Why] | [Cost] |
11. Delivery plan and risks
11.1 Milestones
| Milestone | Scope | Exit criterion | Target date |
|---|---|---|---|
| M1 | Library, Redis cluster, shadow mode in one region | Shadow decisions match expected limits within 5% | YYYY-MM-DD |
| M2 | Enforce for anonymous and free tier | No unexpected 429 complaints for 14 days | YYYY-MM-DD |
| M3 | All tiers, headers documented | Chaos test with Redis down passes | YYYY-MM-DD |
| M4 | Multi-region share rebalancing | Global accuracy within 15% in test | YYYY-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
| ID | Risk | Likelihood | Impact | Mitigation | Owner |
|---|---|---|---|---|---|
| R-01 | Example: Customers with legitimate bursts are throttled | Medium | High | Shadow data review; burst sized from p99 usage | [@name] |
| R-02 | Example: Limiter adds latency during Redis incidents | Medium | Medium | 5 ms timeout and circuit breaker | [@name] |
| R-03 | Example: RateLimit header draft changes | High | Low | Version 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
| ID | Question | Owner | Due |
|---|---|---|---|
| OQ-01 | Example: Do enterprise customers get per-region dedicated limits? | [@name] | YYYY-MM-DD |
| OQ-02 | Example: Should GraphQL requests be costed by query complexity? | [@name] | YYYY-MM-DD |
| [OQ-nn] | [Question] | [@name] | YYYY-MM-DD |
12.2 Glossary
| Term | Meaning |
|---|---|
| Subject | The identity a limit applies to: API key, account, user or IP prefix |
| Burst | Maximum tokens a bucket holds, so the largest instant spike allowed |
| Fail open | Allow requests when the limiter cannot decide |
| Fail closed | Reject requests when the limiter cannot decide |
| Shadow mode | Evaluate and log decisions without enforcing them |
| [Term] | [Meaning] |
12.3 References
| Reference | Use |
|---|---|
| RFC 6585 Additional HTTP Status Codes | Status 429 |
| RFC 9110 HTTP Semantics | Retry-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 APIs | Error body |
| Redis documentation on scripting and Redis Cluster hash tags | 7.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.
関連テンプレート
Database Design Document Template (ERD and Physical Model)
Database design: DBMS choice, conceptual, logical and physical models, indexes, partitioning, integrity, security, backup, capacity
アーキテクチャ・システム設計Chat System Design Template (Reference Design)
Real-time 1:1 and group chat: WebSocket gateways, per-conversation ordering, fan-out, receipts, offline sync, push, E2EE
アーキテクチャ・システム設計Collaborative Editing System Design Template (Reference Design)
Real-time co-editing: OT vs CRDT, sequence CRDT, state-vector sync, presence, offline merge, snapshots and compaction, access