Our /api/v1/discover endpoint gets hammered. It serves streaming-title discovery across 8 regions, and a single popular title going viral in one region can push a region's traffic from 200 req/min to 40,000 req/min in under a minute. When we first launched, we used a naive fixed-window counter in SQLite. It fell over in two ways: the counter row became a write-contention hotspot, and the fixed window let clients fire 2x their quota across the window boundary. A client on a 100 req/min limit could send 100 requests at 11:59:59 and another 100 at 12:00:01 — 200 requests in two seconds, all technically legal.
This post is the rate limiter we run in production at TrendVidStream today: a sliding window log built on Redis sorted sets, with a fixed-window fast path for cheap endpoints, per-region quotas, and a Lua script that makes the whole check atomic. I'll show the failure modes we hit, the exact Redis data structures, runnable PHP and Python, and how we degrade gracefully when Redis itself is unavailable. Our stack is PHP 8.4 on LiteSpeed with SQLite FTS5 for the search index and cron-driven multi-region fetches, so the examples lean that way, but the Redis logic is language-agnostic.
Why fixed windows leak
A fixed window is dead simple: INCR ratelimit:{key}:{minute} with a TTL. The problem is the boundary. Divide time into 60-second buckets and a burst that straddles two buckets sees each bucket's budget in full. For a discovery API where clients scrape aggressively, this is not theoretical — we measured it. The p99.9 request rate for our top 1% of API keys was consistently ~1.8x their nominal limit, and every one of those bursts clustered on the minute boundary.
There are three common fixes, in increasing order of accuracy and cost:
- Sliding window log — store a timestamp per request in a sorted set, count how many fall inside the trailing window. Exact, but memory scales with request volume.
- Sliding window counter — weight the current fixed window by the previous window's count, prorated by how far into the current window you are. Approximate, cheap, O(1) memory.
- Token bucket — refill tokens at a fixed rate, each request costs one. Great for smoothing, but the config (burst size vs refill rate) confuses API consumers who expect "N per minute."
We run the sliding window log for authenticated endpoints where accuracy matters (it feeds our billing tier enforcement) and the sliding window counter for anonymous read endpoints where we just want cheap abuse protection. I'll cover both.
The sorted set approach
The core trick: a Redis sorted set (ZSET) where each member is a unique request ID and its score is the request's timestamp in microseconds. To check the limit you:
- Remove all entries older than
now - window(ZREMRANGEBYSCORE). - Count what remains (
ZCARD). - If under the limit, add the new request (
ZADD) and set a TTL on the whole key. - If over, reject.
Done naively across four separate round trips, this is a race condition: two concurrent requests both read a count of 99 against a limit of 100, and both proceed. You need atomicity. Redis gives you two options — MULTI/EXEC transactions or a Lua script. Lua is strictly better here because it runs server-side in a single step and lets you return the computed count and the retry-after value in one round trip. Here is the script we actually deploy:
-- sliding_window.lua
-- KEYS[1] = the rate limit key, e.g. ratelimit:apikey:AB12:us
-- ARGV[1] = window in microseconds
-- ARGV[2] = limit (max requests in window)
-- ARGV[3] = now in microseconds
-- ARGV[4] = unique member id for this request
-- Returns: {allowed(0/1), current_count, retry_after_ms}
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]
-- 1. drop everything older than the trailing window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 2. how many requests are inside the window right now
local count = redis.call('ZCARD', key)
if count < limit then
-- 3. record this request and refresh the TTL
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, math.ceil(window / 1000))
return {1, count + 1, 0}
end
-- 4. rejected: compute when the oldest entry expires
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after = 0
if oldest[2] then
retry_after = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
end
return {0, count, retry_after}
A few things worth calling out. We set the PEXPIRE every time we admit a request so idle keys self-clean — no separate reaper job. We compute retry_after from the oldest in-window entry, because that's the exact moment a slot frees up; returning that in the Retry-After header makes well-behaved clients back off precisely instead of hammering. And the member id must be unique per request or two requests in the same microsecond collide in the set and undercount. We use now_micros . '-' . random_hex(6).
Wiring it up in PHP
We load the script once with SCRIPT LOAD and call it by SHA with EVALSHA, falling back to EVAL if Redis reports NOSCRIPT (which happens after a Redis restart flushes the script cache). Here's the limiter class we run under PHP 8.4:
<?php
declare(strict_types=1);
final class SlidingWindowLimiter
{
private const SCRIPT = <<<'LUA'
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, math.ceil(window / 1000))
return {1, count + 1, 0}
end
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry = 0
if oldest[2] then
retry = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
end
return {0, count, retry}
LUA;
private string $sha;
public function __construct(
private readonly \Redis $redis,
private readonly int $limit = 100,
private readonly int $windowSeconds = 60,
) {
$this->sha = $this->redis->script('load', self::SCRIPT);
}
/**
* @return array{allowed:bool, count:int, retryAfter:int}
*/
public function check(string $identity, string $region): array
{
$key = sprintf('ratelimit:%s:%s', $identity, $region);
$now = (int) (microtime(true) * 1_000_000);
$window = $this->windowSeconds * 1_000_000;
$member = $now . '-' . bin2hex(random_bytes(6));
try {
$res = $this->evalScript($key, [$window, $this->limit, $now, $member]);
} catch (\RedisException $e) {
// Redis down: fail open, but log it. A dead limiter must not
// take down the API. We alert on this metric instead.
error_log('ratelimit redis failure: ' . $e->getMessage());
return ['allowed' => true, 'count' => 0, 'retryAfter' => 0];
}
return [
'allowed' => (int) $res[0] === 1,
'count' => (int) $res[1],
'retryAfter' => (int) $res[2],
];
}
private function evalScript(string $key, array $args): array
{
$res = $this->redis->evalSha($this->sha, [$key, ...$args], 1);
if ($res === false) {
// NOSCRIPT after a Redis restart — reload and retry once.
$this->sha = $this->redis->script('load', self::SCRIPT);
$res = $this->redis->eval(self::SCRIPT, [$key, ...$args], 1);
}
return $res;
}
}
And the middleware that consumes it. The important part is what we return to the client — standardized headers so consumers can self-throttle:
<?php
declare(strict_types=1);
function enforceRateLimit(
SlidingWindowLimiter $limiter,
string $apiKey,
string $region,
): void {
$result = $limiter->check($apiKey, $region);
header('X-RateLimit-Limit: 100');
header('X-RateLimit-Remaining: ' . max(0, 100 - $result['count']));
if (!$result['allowed']) {
header('Retry-After: ' . $result['retryAfter']);
http_response_code(429);
header('Content-Type: application/json');
echo json_encode([
'error' => 'rate_limited',
'retry_after' => $result['retryAfter'],
'region' => $region,
]);
exit;
}
}
Note the fail-open decision in the catch block. This is a genuine judgment call and it depends on what your API protects. A rate limiter guarding a payment endpoint should fail closed — better to reject traffic than let unlimited requests through. Our discovery API is a read-mostly service where the downside of a brief unlimited window is a bigger cloud bill, not a security breach, so we fail open and page on the redis_failure counter. Decide this deliberately; don't let it be an accident of where you put a try/catch.
Per-region quotas
We run 8 regions and traffic is wildly uneven — US and GB carry roughly 60% of load, while a region like PL might see a tenth of that. A single global limit per API key is wrong in both directions: too loose for the busy regions, too tight for a client legitimately fanning out across all 8. So the key includes the region: ratelimit:{identity}:{region}. That gives each client an independent budget per region, which matches how our cron fetchers and downstream consumers actually behave — they poll region by region.
The tradeoff is that a client hitting all 8 regions gets 8x the aggregate throughput of a single-region client. For us that's correct, because cross-region discovery is exactly the paid feature. If you need a hard global ceiling on top of per-region budgets, run two checks — a per-region ZSET and a global ZSET — and reject if either trips. Just be aware you've doubled your Redis ops per request; we only do the double check on our top billing tier.
The cheaper approximation for anonymous traffic
The sorted-set log stores one member per request. For an anonymous endpoint doing 40k req/min that's 40k ZSET members per key per window — real memory. For those endpoints we use the sliding window counter, which needs only two integers per key. The idea: keep a plain counter for the current fixed window and the previous one, then estimate the sliding count by weighting the previous window by the fraction of it still inside the trailing window.
import time
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
_SCRIPT = r.register_script("""
local cur_key = KEYS[1]
local prev_key = KEYS[2]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- seconds
local elapsed = tonumber(ARGV[3]) -- seconds into current window
local cur = tonumber(redis.call('GET', cur_key) or '0')
local prev = tonumber(redis.call('GET', prev_key) or '0')
-- weight the previous window by the portion still inside the sliding window
local weight = (window - elapsed) / window
local estimated = prev * weight + cur
if estimated >= limit then
return {0, math.floor(estimated)}
end
redis.call('INCR', cur_key)
redis.call('EXPIRE', cur_key, window * 2)
return {1, math.floor(estimated) + 1}
""")
def check(identity: str, region: str, limit: int = 600, window: int = 60):
now = time.time()
cur_window = int(now // window)
elapsed = now - (cur_window * window)
cur_key = f"rl:{identity}:{region}:{cur_window}"
prev_key = f"rl:{identity}:{region}:{cur_window - 1}"
allowed, count = _SCRIPT(
keys=[cur_key, prev_key],
args=[limit, window, elapsed],
)
return bool(allowed), int(count)
if __name__ == "__main__":
ok, n = check("anon-198.51.100.7", "us")
print("allowed" if ok else "blocked", "estimated count:", n)
The approximation error is bounded and small: it assumes requests in the previous window were uniformly distributed, which is rarely exactly true but is close enough for abuse protection. Cloudflare published numbers showing this method's error stays under 1% against real traffic. For 600-per-minute anonymous limits, being off by a handful of requests at the boundary costs us nothing, and the memory footprint is two counters instead of hundreds of ZSET members.
Testing it without waiting real seconds
The worst rate-limiter bugs live at the window boundary, and you cannot find them if your tests sleep for real time. Inject the clock. Both the PHP and Python versions take now as an argument to the Lua script precisely so tests can drive time forward deterministically. Here's the boundary test that would have caught our original fixed-window leak:
def test_no_boundary_burst(fake_redis):
limiter = SlidingLog(fake_redis, limit=100, window=60)
t = 1_000_000_000_000_000 # microseconds
# Fill the budget at the end of a window.
for _ in range(100):
allowed, _, _ = limiter.check("k", "us", now=t)
assert allowed
# 101st request in the same instant must be rejected.
allowed, _, _ = limiter.check("k", "us", now=t)
assert not allowed
# Cross the fixed-window boundary (+1s). A FIXED window would reset
# here and allow 100 more. The sliding window must still reject,
# because 100 requests are within the trailing 60s.
allowed, _, retry = limiter.check("k", "us", now=t + 1_000_000)
assert not allowed
assert retry > 0
# Only after the full window passes does a slot free up.
allowed, _, _ = limiter.check("k", "us", now=t + 60_000_001)
assert allowed
That last block is the whole point of the exercise. A fixed window passes the first three assertions and fails the fourth — it lets the burst through one second after the boundary. Run this against your implementation before you trust it.
Operational notes from running this in production
A few things the tutorials don't tell you, learned from a year of this in production:
-
Clock skew across app servers matters. The Lua script uses
nowpassed from the app, notredis.call('TIME'). That means two app servers with drifting clocks compute slightly different windows. We sync with chrony and keep drift under 50ms; if you can't guarantee that, use Redis's ownTIMEcommand inside the script for a single authoritative clock, at the cost of one extra call. -
Watch key cardinality.
ratelimit:{apikey}:{region}with per-IP anonymous keys can explode into millions of keys. Set aggressive TTLs (we usewindow * 2) and monitordb0:keysinINFO. Our anonymous keyspace churns ~2M keys/hour and stays flat in memory because everything expires. -
The
Retry-Afterheader pays for itself. After we started returning accurate retry values, well-behaved clients stopped retry-storming us. 429 responses dropped by 70% in the following week purely because clients backed off correctly instead of guessing. - Log the estimated count, not just allow/deny. We ship the count into our metrics pipeline so we can see who is approaching their limit before they hit it. That turned angry "why am I blocked" support tickets into proactive "you're at 85% of your quota" emails.
Conclusion
The sliding window log on Redis sorted sets gives you exact rate limiting with graceful, precise back-off signals, and the Lua script makes the check atomic in a single round trip. Where accuracy is worth the memory — authenticated, billed endpoints — use the log. Where you just need cheap abuse protection at high volume, the two-counter sliding window approximation is under 1% off and costs almost nothing. Key the limiter by region if your traffic is geographically lopsided like ours, decide fail-open versus fail-closed on purpose rather than by accident, and test the window boundary with an injected clock or you will ship the exact leak you were trying to prevent. The full limiter — Lua script, PHP class, Python counter, and boundary tests — is what runs our discovery API across all 8 regions today; take it, adapt the limits to your traffic, and instrument it before you trust it.
Top comments (0)