Design a Distributed Rate Limiter
Protect APIs from overload: Token Bucket in Redis with Lua scripts, Sliding Window Counters, race condition handling, and Gateway edge integration.
Distributed Rate Limiter with Atomic Redis Lua Engine ๐ชฃ
Centralized Redis Cluster executing atomic Token Bucket Lua scripts with local in-memory fallback and HTTP 429 throttling.
01.1. Functional & Non-Functional Requirements
A Distributed Rate Limiter throttles excessive client requests to prevent cascading system outages, mitigate DDoS/brute-force attacks, and enforce tiered SaaS subscription billing quotas.
Functional Requirements
- Multi-Dimension Throttling: Limit requests based on IP address, authenticated
user_id, API Key (tenant_id), or specific endpoints (e.g., login vs search). - Clear Feedback Headers: When rate limits are reached, reject requests with HTTP 429 Too Many Requests and include standard headers:
X-RateLimit-Limit: Maximum requests allowed per window.X-RateLimit-Remaining: Number of remaining tokens in current window.X-RateLimit-Reset: Unix epoch timestamp when limit refills.Retry-After: Seconds until the client can retry.
- Configurable Rules Engine: Support dynamic updates to rate-limiting rules without restarting services.
Non-Functional Requirements
- Sub-Millisecond Overhead: Rate-check latency must remain
< 2 ms(p99) so it does not degrade overall API response time. - Distributed Accuracy & Concurrency Safety: Zero race conditions across thousands of parallel web server nodes.
- High Fault Tolerance (Fail-Open Policy): If the rate-limiting Redis cluster fails or experiences a network partition, the system must degrade gracefully (fail-open or local in-memory estimate) rather than taking down all user traffic.
02.2. Algorithmic Deep Dive: Comparing Rate Limiting Algorithms
Choosing the appropriate rate-limiting algorithm depends on burst tolerance, memory constraints, and computational complexity:
| Algorithm | Mechanism | Pros | Cons |
|---|---|---|---|
| Token Bucket (Industry Favorite) | Tokens refill at fixed rate r up to capacity C. Each request consumes 1 token. | Allows short bursts up to C, memory efficient (2 values per user). | Refill math requires precise timestamps. |
| Leaky Bucket (FIFO) | Requests enter a queue and leak out at a constant fixed rate. | Completely smooths out traffic spikes. | Bursts are queued or dropped, delaying time-critical requests. |
| Fixed Window Counter | Divides time into fixed 1m windows with an integer counter. | Minimal memory (1 counter), simple implementation. | Traffic spikes at window boundaries allow 2ร burst (200\% quota). |
| Sliding Window Log | Stores timestamp of every request in a sorted set; removes entries older than window. | 100\% mathematically accurate. | High memory overhead (O(N) timestamps stored in RAM). |
| Sliding Window Counter | Weighted sum of current and previous fixed window counters. | Low memory (2 counters), eliminates window boundary spike issue. | Assumes uniform traffic distribution in prior window (~0.05% error). |
03.3. Solving Concurrency Race Conditions with Atomic Redis Lua Scripts
In a distributed environment with 100 API gateway instances, a naive GET -> calculate -> SET pattern causes severe race conditions:
codeServer A: GET tokens (reads 1) Server B: GET tokens (reads 1) Server A: tokens = 1 - 1 = 0 -> SET tokens 0 (Request Allowed) Server B: tokens = 1 - 1 = 0 -> SET tokens 0 (Request Allowed - Race Condition!)
The Winning Solution: Atomic Redis Lua Script
Redis executes Lua scripts in a single-threaded atomic transaction, guaranteeing no other command interleaves during execution.
lua-- KEYS[1]: Rate limit key (e.g. "rate:user_981:api") -- ARGV[1]: Bucket capacity (e.g. 100) -- ARGV[2]: Refill rate per second (e.g. 10) -- ARGV[3]: Current timestamp (Unix epoch seconds with ms) -- ARGV[4]: Requested tokens (e.g. 1) local key = KEYS[1] local capacity = tonumber(ARGV[1]) local refill_rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) local data = redis.call("HMGET", key, "tokens", "last_updated") local tokens = tonumber(data[1]) local last_updated = tonumber(data[2]) if tokens == nil then tokens = capacity last_updated = now else local elapsed = math.max(0, now - last_updated) tokens = math.min(capacity, tokens + (elapsed * refill_rate)) last_updated = now end if tokens >= requested then tokens = tokens - requested redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated) redis.call("EXPIRE", key, math.ceil(capacity / refill_rate) * 2) return {1, math.floor(tokens)} -- Allowed, remaining tokens else redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated) return {0, math.floor(tokens)} -- Rejected (HTTP 429) end
04.4. Distributed Architecture & Edge Integration
Where to Place the Rate Limiter?
- Edge CDN (Cloudflare / Fastly): Blocks volumetric DDoS and Layer 7 scraping before traffic enters cloud infrastructure.
- API Gateway (Envoy / Kong / Zuul): Enforces fine-grained user authentication limits, tenant quotas, and tier enforcement (Free vs Enterprise).
- Application Middleware: Enforces internal business rate limits (e.g., maximum 3 password reset attempts per hour).
Sharding & Scaling the Redis Layer
- Consistent Hash Partitioning: Rate limit keys (
"rate_limit:{user_id}") are sharded across a Redis Cluster using 16,384 hash slots. - Hash Tags: Use Redis hash tags (e.g.,
"rate_limit:{{tenant_123}}:resource") to ensure all limits for a specific tenant reside on the same Redis shard for batch script execution.
05.5. High Availability, Failover & Multi-Datacenter Synchronization
Fail-Open vs Fail-Closed Policy
- Mission-Critical Endpoints (e.g.,
/checkout,/login): If Redis becomes unreachable, Fail-Open with a local in-memory rate limiter (e.g., Token Bucket in local RAM per Envoy worker) to prevent catastrophic customer-facing downtime. - High-Risk Financial Endpoints (e.g.,
/transfer_money): Fail-Closed to prevent double-spending attacks.
Multi-Region WAN Synchronization
Synchronizing Redis writes across cross-region datacenters (US-East to EU-West) introduces 100 ms WAN latency.
- Regional Token Buckets: Allocate regional sub-quotas (e.g., US gets
60\%, EU gets40\%of global quota). - Asynchronous Batch Synchronization: Edge servers track local token consumption and flush deltas to global Redis in batches every
100 ms.
โ๏ธArchitectural Trade-offs & Production Realities
Architectural Advantages
- Token Bucket allows smooth handling of natural network traffic bursts while enforcing long-term quotas
- Atomic Redis Lua scripting eliminates concurrency race conditions with sub-millisecond execution
- Decoupled Gateway tier protects all downstream microservices centrally
Trade-offs & Constraints
- Redis becomes a critical-path dependency requiring robust clustering and failover handling
- Multi-region synchronization requires token partitioning or eventual consistency compromises
Stripe uses Redis-backed Token Bucket limiters embedded within Envoy edge proxies to enforce 100 req/sec live limits per API key, gracefully throttling non-critical webhook traffic during merchant traffic surges.
๐ฏ Staff+ Engineering Takeaways
- Token Bucket is the industry standard for API rate limiting due to burst tolerance and memory efficiency.
- Redis Lua scripts execute atomically in memory, preventing distributed race conditions.
- Return HTTP 429 with informative `Retry-After` headers when clients exceed limits.
- Use local memory fallbacks and Fail-Open policies to survive Redis cluster outages.
Topic Knowledge Assessment ๐ง
Step through 2 scenario questions to test your staff-level grasp.
Why must the Token Bucket rate limiter logic in Redis be executed via a Redis Lua script rather than individual GET and SET commands from the application server?
How clear and staff-actionable was this system breakdown?