Lab: Rate Limiter Races¶
Pairs with: Rate Limiting
No app code needed — just concurrent shell processes hitting a real Redis instance. Reproduces two real races: a naive INCR+EXPIRE counter whose window never actually closes under continuous traffic, and a check-then-act limiter that lets 20 requests through a limit of 5 under real concurrency. Both get fixed with one atomic Lua script.
docker-compose.yml¶
name: labs-rate-limiter
services:
redis:
image: redis:7
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 3s
retries: 20
atomic_incr_expire.lua¶
Fixes the "TTL never expires" bug — EXPIRE only fires on the request that creates the key:
-- Fixed-window counter, done correctly: EXPIRE is set only on the request
-- that creates the key (count == 1), never re-armed on every request.
-- Run atomically via EVAL so no other client can interleave between the
-- INCR and the conditional EXPIRE.
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return count
atomic_fixed_window.lua¶
Fixes the check-then-act race — increment and limit check happen inside one atomic EVAL, so there's no separate read step for two requests to race on:
-- Atomic fixed-window rate limiter: increment-then-check, in one EVAL call.
-- No client can observe a stale count between "read" and "act" because
-- there is no separate read — the increment and the limit check happen
-- inside the same atomic script execution.
-- KEYS[1] = counter key, ARGV[1] = window seconds, ARGV[2] = limit
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if count > tonumber(ARGV[2]) then
return 0 -- deny
else
return 1 -- allow
end
Exercises¶
The full walkthrough (reproduce both races with exact commands, verify the fix count is exactly 5 allowed / 15 denied under concurrency) lives in the lab's README: