Rate limiting with counters

Redis · Practical · 10 min

APIs, logins and SMS sending all need limits, and Redis is the usual shared store for them.

A fixed-window limiter counts requests per client per time window. The key includes the window number, e.g. rl:ana: plus now // 60 for minutes. Each request runs INCR key; if the result is 1, it also sets EXPIRE key 60. If the count exceeds the limit, reject. Because INCR is atomic, concurrent requests are counted correctly across all app servers.

Send INCR and EXPIRE together in a pipeline or transaction (or use a Lua script) so a crash between them can't leave a counter without a TTL. Setting the expiry on every request also works; it just slides the window slightly.

Fixed windows allow bursts at boundaries: a client could send the full limit at 12:00:59 and again at 12:01:00. A sliding-window log fixes this with a sorted set per client:

  • ZREMRANGEBYSCORE key -inf now-window drops old entries,
  • ZADD key now unique-id records this request,
  • ZCARD key counts requests in the window,
  • EXPIRE key window cleans up idle clients.

That costs memory per request, so for high limits use a sliding-window counter (weighting the previous fixed window) or a token bucket implemented in a Lua script.

Return 429 Too Many Requests with a Retry-After header, and key limits by user or API key rather than IP alone where possible.

Example

import time
import redis

r = redis.Redis()

def allow_fixed(user, limit=5, window=60):
    key = f"rl:{user}:{int(time.time()) // window}"
    with r.pipeline() as p:                 # MULTI/EXEC: both or neither
        p.incr(key)
        p.expire(key, window)
        count, _ = p.execute()
    return count <= limit

def allow_sliding(user, limit=5, window=60):
    key, now = f"rls:{user}", time.time()
    with r.pipeline() as p:
        p.zremrangebyscore(key, "-inf", now - window)
        p.zadd(key, {f"{now}": now})
        p.zcard(key)
        p.expire(key, window)
        _, _, count, _ = p.execute()
    return count <= limit

print([allow_fixed("ana", limit=3) for _ in range(5)])
# [True, True, True, False, False]

Remember

  • Fixed window: INCR a per-window key and EXPIRE it
  • Send INCR and EXPIRE together so no counter lives forever
  • Sliding-window logs use a sorted set of timestamps
  • Fixed windows allow boundary bursts; sliding windows don't

Watch out for

  • Calling EXPIRE only when INCR returns 1 in a separate request: if that request fails, the key never expires and the user is blocked forever.

Check yourself

  1. A fixed-window limiter allows 100 requests per minute. What is the worst case a client can send within any 2 seconds?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: redis.io/docs/latest/commands/incr/

Builds on

Unlocks

Nothing builds on this yet.