Lesson 15 / 25

Distributed Rate Limiting

Enforce limits across many instances with Redis or a gateway.

One limit, many servers

With ten API instances behind a load balancer, a limit counted in each instance's memory allows ten times the intended rate. Distributed rate limiting keeps counters in a shared, fast store, typically Redis, and updates them atomically, usually with a Lua script or atomic commands so concurrent requests cannot both pass on the last token. Trade-offs: every request adds a network round trip to the limiter; the limiter becomes a dependency, so decide whether to fail open (allow requests if Redis is unavailable, favouring availability) or fail closed (reject, favouring protection); and very hot keys can overload one Redis shard. Alternatives: let the API gateway or service mesh enforce limits (Kong, Envoy's global rate limit service, cloud API gateways), or use approximate local limits per instance (global limit divided by instance count), which is cheap and often good enough when traffic is balanced.

Atomic token bucket in Redis with Lua

The script refills and consumes in one atomic step on the Redis server.

-- KEYS[1] = bucket key, ARGV = rate_per_sec, capacity, now_ms, cost
local rate     = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

local state  = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts     = tonumber(state[2]) or now

tokens = math.min(capacity, tokens + (now - ts) / 1000 * rate)
local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) + 1000)
return { allowed, math.floor(tokens) }

Decide fail-open or fail-closed explicitly

For a public API's fairness limits, failing open during a Redis outage is usually right. For login brute-force protection, failing closed (or falling back to strict local limits) may be safer. Write the decision down.

Quick check: Why must a distributed rate limiter update its counter atomically?

  • Redis requires it for all keys
  • To make responses smaller
  • Otherwise concurrent requests on different instances could all read the same count and exceed the limit
  • To avoid using TTLs
Answer

Otherwise concurrent requests on different instances could all read the same count and exceed the limit — Atomic read-modify-write prevents race conditions between instances.