A rate limit is a budget for admitted work, defined by its unit, identity, scope, and time rule. “Five requests” is incomplete without saying whose requests, across which endpoints and workers, during which interval, and what happens after exhaustion. A shared counter helps only when every relevant admission uses the same authoritative decision.
State the policy before counting
A request can cost one unit, several estimated work units, or a number of bytes. Count attempted admission, successful execution, and refunded work differently only when the contract says so. Accepting a request into the budget does not guarantee that its downstream operation succeeds.
Window algorithms also differ. A fixed-window counter can allow bursts near a boundary; a rolling-window rule measures a different interval. A token bucket permits stored credit to be spent quickly and does not automatically pace departures. None of these alone limits the number of slow operations still running.
Per-account, per-route, regional, and service-wide limits answer different questions. A remaining-budget value is a snapshot, not a reservation: another worker can spend credit before the caller uses it. Independent worker-local counters multiply the effective allowance unless a documented partitioning rule divides the intended budget between them.
Make the decision atomic
A shared Redis key does not make a sequence of client commands atomic. Two callers can both read remaining capacity, both decide to proceed, and then both increment. Each INCR is atomic, but the earlier comparison was outside that operation. The total can exceed the promised allowance.
The example places comparison, first-counter creation with expiration, and increment inside one short Lua execution. Its window starts at the first accepted request; rejection neither increments the count nor extends the deadline. All callers supply the same key and fixed positive parameters. The fixture owns that numeric counter exclusively.
This is a first-arrival-anchored window, not a continuously sliding quota. The script’s noninterleaving property is also not rollback, durable reservation, or a transaction with the application’s business operation. Keep scripts short because Redis blocks other server activity while executing them. Treat connection loss as an uncertain result, not proof that credit was never spent.
Explain the refusal accurately
RFC 6585 defines HTTP 429 for excessive request rates, but leaves identity and counting policy to the server. The response should explain the condition and may include Retry-After. A 429 response must not be stored by a cache. Generic service unavailability is a different condition from a particular caller exceeding its allowance.
Retry-After can communicate a wait without guaranteeing future capacity. If exposing remaining capacity or reset metadata, define their units and scope in the API contract. Historical X-RateLimit field conventions are not a universal interpretation supplied by RFC 6585. Do not invent a standard merely because several APIs use similar names.
When it breaks
Split check/update operations overspend under contention. Separately creating a counter and attaching expiration can leave an immortal counter if the client stops between commands. Eviction, restart, or asynchronous failover can also lose budget state; atomic execution on one live server does not prove a hard ceiling across those failures.
Document what an unavailable budget store means for admission. Silently treating an error as an empty counter creates extra allowance. Protect the limiter’s own latency and capacity, observe allowed and denied work together, and distinguish resource protection from arbitrary early rejection of requests still entitled to proceed.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Redis 8.10.1, redis-py 8.1.0, and Python 3.14.4. The setup builds Redis core and installs the client in a temporary environment.
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
Run the Python block with "$redis_lab/venv/bin/python". It starts only its own Redis process on a private Unix socket, verifies its process ID, disables TCP and persistence, and cleans up afterward. Sixteen independent client connections contend for five admissions. The first path deliberately demonstrates a broken check/update sequence.
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
from threading import Barrier
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
def connection(endpoint):
return redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-budget-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0", "--unixsocket", endpoint,
"--unixsocketperm", "700", "--dir", directory,
"--save", "", "--appendonly", "no",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = connection(endpoint)
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
assert time.monotonic() < deadline, "Redis did not start"
time.sleep(0.02)
assert client.info("server")["process_id"] == process.pid
yield client, endpoint
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
acquire = """
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= tonumber(ARGV[1]) then
return {0, current, redis.call('PTTL', KEYS[1])}
end
if current == 0 then
redis.call('SET', KEYS[1], '1', 'PX', ARGV[2])
else
redis.call('INCR', KEYS[1])
end
return {1, current + 1, redis.call('PTTL', KEYS[1])}
"""
LIMIT, WINDOW_MS, WORKERS = 5, 60000, 16
with instance() as (control, endpoint):
def concurrent(operation):
barrier = Barrier(WORKERS)
def run(_):
with connection(endpoint) as client:
return operation(client, barrier)
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
return list(pool.map(run, range(WORKERS)))
control.set("budget:unsafe", 0)
def split_check(client, barrier):
observed = int(client.get("budget:unsafe"))
barrier.wait(timeout=5) # All callers read before any caller increments.
if observed >= LIMIT:
return False
client.incr("budget:unsafe")
return True
unsafe = concurrent(split_check)
assert sum(unsafe) == WORKERS
assert int(control.get("budget:unsafe")) == WORKERS
print("split GET/INCR admits 16 against a budget of 5")
def atomic_check(client, barrier):
barrier.wait(timeout=5)
return client.eval(acquire, 1, "budget:shared", LIMIT, WINDOW_MS)
results = concurrent(atomic_check)
assert sum(reply[0] for reply in results) == LIMIT
assert sorted(reply[1] for reply in results if reply[0]) == list(range(1, LIMIT + 1))
assert all(reply[1] == LIMIT for reply in results if not reply[0])
assert all(0 < reply[2] <= WINDOW_MS for reply in results)
assert control.get("budget:shared") == b"5"
print("one atomic script admits 5 and rejects 11 across independent client connections")
ttl_before = control.pttl("budget:shared")
assert control.eval(acquire, 1, "budget:shared", LIMIT, WINDOW_MS)[:2] == [0, LIMIT]
assert 0 < control.pttl("budget:shared") <= ttl_before
assert control.get("budget:shared") == b"5"
print("a rejection neither spends more budget nor refreshes the window")
assert control.pexpire("budget:shared", 50)
deadline = time.monotonic() + 3
while control.exists("budget:shared"):
assert time.monotonic() < deadline, "test deadline did not expire"
time.sleep(0.01)
first = control.eval(acquire, 1, "budget:shared", LIMIT, WINDOW_MS)
assert first[:2] == [1, 1] and 0 < first[2] <= WINDOW_MS
print("after deliberately shortening and expiring the test window, the next admission starts at 1")
A barrier makes every unsafe caller read before any increments, reproducibly admitting sixteen. The atomic script then admits exactly five, rejects eleven, and stores five; successful replies contain each count from one through five once. Rejection leaves both the count and original expiration intact.
The test deliberately shortens its own deadline to fifty milliseconds, waits for real expiration, and verifies that the next admission starts at one with a new window. This avoids waiting a full minute without claiming to have measured a minute-long workload. Cluster operation, failover, persistence, HTTP middleware, production latency, and coupling admission to business side effects are not tested.
Further reading
Sources
- RFC 6585, section 4’s 429 semantics, optional Retry-After, and caching prohibition, checked 2026-09-08.
- RFC 9110, Retry-After and service-unavailability semantics, checked 2026-09-08.
- Redis scripting introduction, atomic execution and blocking behavior, checked 2026-09-08.
- Redis EVAL, explicit key and argument passing, checked 2026-09-08.
- Redis INCR, counter operations and rate-limiter race conditions, checked 2026-09-08.
- Redis PEXPIRE, millisecond expiration used in the isolated test, checked 2026-09-08.
- Redis 8.10.1 build instructions, core build and allocator selection, checked 2026-09-08.