shreyansh

Rate Limiter

30-Second Glance

  • What: Distributed throttle — N requests per window per key (user, IP, API key, endpoint) — enforced at the gateway.
  • Scale: ~1M checks/s across the edge; each check must add <2 ms p99 and survive Redis loss (fail-open for reads, fail-closed for writes).
  • Core trick: Token bucket / sliding-window-counter evaluated atomically in Redis via Lua; rules versioned + pushed to gateways.
  • Hardest part: Exactness vs cost (sliding-window-log is exact but O(n) memory); races across app hosts; clock skew; multi-DC consistency.

Interactive playground Fire bursts at a live token bucket, flip algorithms, split quotas across DCs: Open Rate Limiter simulator →

1. Requirements

Functional:

  1. Limit by multiple dimensions: per-user, per-IP, per-API-key, per-endpoint, global floor.
  2. Multiple algorithms: token bucket, leaky bucket, fixed window, sliding-window counter (at least two in prod).
  3. Configurable rules per tier/endpoint with runtime updates (no deploy).
  4. Headers on every response: X-RateLimit-Limit/Remaining/Reset; 429 with Retry-After on exceed.
  5. Audit trail of rule changes + top-offender visibility.

Non-functional:

Requirement Target
Added latency p99 < 2 ms per check at edge
Availability Limiter failure must not take down the product → fail-open on read path
Consistency Approximate is fine (±1–2 requests at window edges acceptable); no sustained over-admission
Throughput 1M+ checks/s, horizontally scalable

Out of scope:

  • Billing/metering (consumes limiter signals, separate system).
  • WAF/bot detection (feeds blocklists in, separate system).
  • Per-request cost weighting beyond simple integer costs (v2).

2. Capacity Estimation

Assumptions: 100 edge gateways × 10k req/s each = 1M checks/s; 10M distinct limit-keys active; rule evaluation = 1 Redis Lua call.

Metric Math Result
Check QPS (avg / peak) 1M avg, 3× peak 1M / 3M
Redis ops 1 Lua eval per check (+1 pipelined TTL refresh) ~1–2M ops/s → ≥20 shards at ~100k ops/s/shard, before replicas
Memory (token bucket) 10M keys × ~300 B (key + tokens + ts + TTL overhead) ~3 GB — trivially fits; sliding-window-log would be 10M × 100 entries × 16 B ≈ 16 GB+ (why we avoid it)
Bandwidth 1M × ~500 B round-trip ~500 MB/s edge→Redis — must be same-AZ; cross-AZ doubles cost
Rule-push fan-out 10k gateways × 1 KB rules × 1/min noise —itatively negligible

3. API Design

The limiter is enforcement middleware, not a CRUD product — but it exposes two surfaces:

# Internal check (gateway -> limiter sidecar/service, or embedded Lua call)
POST /v1/check
{ "key": "user:8123:POST:/api/v1/shorten",
  "cost": 1,
  "policy": { "algorithm": "token_bucket", "capacity": 100, "refill_per_sec": 10 } }

200 OK
{ "allowed": true, "remaining": 87,
  "limit": 100, "reset_ms": 1300 }

429 Too Many Requests
Retry-After: 2
{ "allowed": false, "remaining": 0, "reset_ms": 2000 }
Endpoint Purpose Notes
POST /v1/check Allow/deny + debit Idempotent per request-ID (retries don't double-spend); cost supports weighted endpoints
PUT /v1/rules/{scope} Upsert rule Versioned (rule_version); canary % rollout; audit-logged
GET /v1/rules/{scope} Fetch effective rule Gateways long-poll/cache 30 s; version header for skew detection
GET /v1/top-offenders?window=1m Ops visibility Served from analytics rollup, not Redis
  • Response headers on every proxied response: X-RateLimit-Limit, -Remaining, -Reset, plus X-RateLimit-Policy for debuggability.
  • Quota tiers compose: request must pass global → endpoint → user/IP checks (deny on first fail, debit only on full pass — or debit-all-then-refund; pick debit-on-pass and say why: no refund races).

4. Data Model & Storage

Entity Store Partition / shard key Why this store
Bucket state {tokens, last_refill_ts} / window counters Redis Cluster (in-memory, AOF on) limit-key (scope:id:endpoint) Sub-ms atomic ops; keyspace sharded by hash; TTL auto-cleans idle keys
Rules {scope, algorithm, params, version} Postgres (source of truth) + pushed to gateway memory scope Tiny, strongly consistent, auditable; Single Leader Replication is plenty
Hit/deny analytics Kafka → ClickHouse (scope, minute) Top-offender + capacity planning; off the hot path
-- Redis key layout (token bucket), all with PEXPIRE = window cap
-- ratelimit:{scope}:{id}:{endpoint} -> HASH { tokens, ts, version }
-- Fixed/sliding counters: ratelimit:{...}:{window_start} -> INT with EXPIRE
  • Redis persistence: AOF every second — losing 1 s of buckets on failover only over-admits slightly; acceptable per requirements. No RDB restores into prod (stale buckets under-admit).
  • Gateway caches rules in memory (30 s TTL + version check) so rule reads never add a hop.

5. High-Level Design

Request path: client → L4 LB → API gateway (Envoy/nginx) → limiter check (embedded Lua over pooled Redis conn, rules from local cache) → allow: proxy to service with rate headers; deny: 429 immediately. Control path: admin → config service (Postgres) → pub/sub rule push → gateways.

loading diagram…
  • Gateway (enforcement point): runs the check inline before proxying; holds rule cache; implements fail-open/closed policy.
  • Redis Cluster: one Lua eval per check — fetch bucket, refill by elapsed time, admit-or-deny, set TTL — atomically.
  • Config service: rule CRUD, versioning, canary rollout, audit log.
  • Analytics side-path: allow/deny counters → Kafka → ClickHouse → dashboards + auto-tune suggestions.
  • Fallback: in-process leaky counter (per-gateway, approximate) when Redis is unreachable.

6. Deep Dives

6.1 Distributed token bucket on Redis (the exact mechanism)

All logic in one Lua script so fetch-refill-decide-store is atomic across 100 gateways:

tokens = min(capacity, stored + elapsed * refill_rate)
if tokens >= cost: tokens -= cost; allowed = 1
else: allowed = 0
store(tokens, now); PEXPIRE(key, ceil(capacity / refill_rate) * 2)
return {allowed, tokens}

Why this shape: elapsed-time refill needs no background timers; TTL = 2× drain time bounds memory (idle keys vanish); returning remaining lets the gateway set headers without a second round-trip. Burst = capacity, sustained = refill_rate — two knobs map directly onto product tiers ("100 burst, 10/s sustained"). Clock: use Redis server TIME inside Lua, not client clocks — eliminates client-skew over-admission. Precision: milliseconds; sub-ms refill rounds down (slightly strict, safe direction).

6.2 Sliding windows: fixed vs log vs counter

Approach Pros Cons Use when
Fixed window (counter per minute) 1 key, O(1) 2× burst at boundaries (spike at :00) Coarse quotas (daily API calls)
Sliding-window log (store each ts) Exact O(n) memory + trim cost; 16 GB+ at our scale Low-rate, high-value actions (password resets, OTPs)
Sliding-window counter (chosen for general use) O(1), smooth ±small edge error Standard per-user/per-IP throttles

Counter formula: estimate = current_window_count + prev_window_count × overlap_fraction. Two integer keys with EXPIRE, one INCR + one GET pipelined — no Lua needed. Name the boundary-spike flaw of fixed windows in the interview; it's the classic follow-up.

6.3 Races, clocks, multi-DC, fail-open vs fail-closed

  • Races: without Lua, read-refill-write interleaves across hosts over-admit 2–5× under contention. Lua (or EVALSHA) is the whole fix; keep scripts deterministic and under ~1 ms.
  • Sticky routing alternative: route same user to same gateway and keep buckets in-process — zero Redis cost, but rebalancing + gateway loss corrupts limits; use only as degraded fallback, say so explicitly.
  • Multi-DC: each DC enforces against local Redis with the global quota divided (e.g. 3 DCs → each gets limit/3 with 20% headroom borrowing). True global exactness needs cross-DC coordination (Linearizability per check ≈ death at 1M/s) — so we accept bounded over-admission and document the bound.
  • Fail-open vs closed: read path (GET product data) → fail-open (serve, alert); mutating/billing paths → fail-closed (reject with 503 + metric). Decide per-endpoint in the rule, not globally.
  • Per-user vs per-IP: users share NAT IPs (mobile carriers!) — pure IP limits cause collateral 429s. Policy: authenticated → limit by user ID (generous) + loose IP backstop (DDoS only); anonymous → stricter IP limit + captcha escalation path.

7. Bottlenecks, Trade-offs & Monitoring

First bottleneck at 10x: Redis shard hot-spotting on viral keys (one celebrity's key = one hash slot) — mitigate with local gateway pre-aggregation (probabilistic admission for ultra-hot keys) + slot splitting by appending :n shards for top-K keys. Next: gateway→Redis AZ bandwidth.

Approach Pros Cons Use when
In-process (per host) Zero infra, fastest Wrong under LB spread; lost on restart Single-box / SDK client-side
Central Redis + Lua (chosen) Correct across fleet, <2 ms Redis is now critical path (mitigate: fallback) Distributed services
Sticky-route + local No central infra Rebalance skew, failover corruption Degraded fallback only
Metric Alert if
Check p99 added latency > 5 ms for 5 min
429 rate per scope > 3× baseline (mis-tuned rule or attack)
Redis error / timeout rate > 0.1% (fail-open is masking — page anyway)
Rule version skew (gateways behind) any gateway > 2 min stale
Deny-log Kafka lag > 2 min

Interview one-liners

  • Q: Why Lua? / A: Makes refill-and-debit atomic across all gateways; without it, concurrent checks over-admit several-fold.
  • Q: Exact global limits across DCs? / A: No — divide quota per DC with headroom; exactness would cost a coordination round-trip per request.
  • Q: Limiter is down — fail open or closed? / A: Per endpoint: reads fail open, money-mutations fail closed; either way, page on it.

try it live

Steppable request flow, capacity sliders and failure toggles — open the interactive simulator →