Loading…
Loading…
Design a rate limiter that restricts the number of requests a client can make to an API within a time window. Support multiple limiting strategies and work correctly in a distributed environment.
For 10M users, each making up to 100 req/min:
API Request
│
▼
[Rate Limiter Middleware]
│ checks Redis
├── Allowed → forward to API handler
└── Denied → 429 Too Many Requests
[Redis Cluster]
key: ratelimit:{userId}:{window}
value: request count
TTL: window size
A naive GET count then INCR from the application has a race window between two app servers checking simultaneously — both could read "under limit" before either increments. Two fixes:
INCR ratelimit:{userId}:{window}
EXPIRE ratelimit:{userId}:{window} <window_seconds> -- only on first increment
INCR itself is atomic in Redis, but the INCR + conditional EXPIRE pair is not — the standard fix is a Lua script, which Redis executes atomically as a single operation:
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return current
Running this via EVAL guarantees the increment-and-maybe-expire happens as one atomic unit, closing the race window entirely.
The rate limiter is middleware, not an API itself. Response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1735689600
Retry-After: 37 (only on 429)
Fixed window counter:
SET ratelimit:user123:1735689600 0 EX 60
INCR ratelimit:user123:1735689600
Sliding window log (more accurate, more memory):
ZADD ratelimit:user123 <timestamp> <requestId>
ZREMRANGEBYSCORE ratelimit:user123 0 <now - window>
ZCARD ratelimit:user123
Use a Redis Cluster to distribute the keyspace. Each rate limiter instance talks to the same Redis cluster, so limits are enforced globally across all API servers.
Production systems commonly layer these: a coarse gateway-level limit as the primary defense, with finer per-service limits for specific expensive endpoints.
A production API gateway rarely enforces one global limit — different tenants (or pricing tiers) need different limits, and those limits need to be changeable without a redeploy. This means the rate limit rule itself (not just the counter) needs to be looked up dynamically per request: GET ratelimit-rule:{tenantId} from a fast config store (Redis, or an in-memory cache refreshed periodically from a config service) rather than hardcoded in application code. A common addition here is a quota (a longer-window cap — e.g. 1M requests/month) layered on top of the short-window rate limit (100 req/min) — the two serve different purposes: rate limiting protects against short bursts, quota enforcement protects the billing/business model, and a gateway often needs to check both independently.
Since a gateway-level limiter sits in front of every request to every backend service, its own reliability matters more than a per-service limiter's would — a gateway rate limiter that adds significant latency or becomes unavailable affects the entire system, not just one endpoint. This is why the fail-open-vs-fail-closed decision (see Failure Scenarios below) is especially consequential at the gateway layer, and why the Redis dependency backing it needs its own health checks (Health Checks) feeding into the gateway's own resilience posture (a circuit breaker around the Redis call itself, defaulting to fail-open on timeout, is a common production pattern).
Redis is the single source of truth. Use Redis Cluster for horizontal scaling and Redis Sentinel for failover.
If Redis is unavailable: fail open (allow all requests) or fail closed (deny all). Most APIs choose fail open to avoid an outage becoming a complete blackout.