How the API rate limiter works
A Redis-backed token bucket in TypeScript, read once
Capacity60 tokensburst size
Refill1 token/s60 per minute steady
Redis timeout25 msthen fail open
Idle expiry120 sbucket resets to full
The flow
Read this diagram top to bottom. The diamond is the only decision; everything else is bookkeeping. The dashed line is the failure path that lets traffic through.
USERSVCDBDECISIONEXTfocusrequest / flowasync / reply / optional
What a bucket does over time
Start full at 60. A burst of 60 at 1 s empties it. It refills 1 token per second, so by 30 s it holds 29. A second burst of 25 takes it to 4, and it creeps back to the 60 cap by 90 s.
Data
| time | tokens | Event |
|---|---|---|
| 0 s | 60 tokens | |
| 10 s | 9 tokens | burst of 60 |
| 20 s | 19 tokens | |
| 30 s | 29 tokens | |
| 40 s | 13 tokens | burst of 25 |
| 50 s | 23 tokens | |
| 60 s | 33 tokens | |
| 70 s | 43 tokens | |
| 80 s | 53 tokens | |
| 90 s | 60 tokens | |
| 100 s | 60 tokens |
The four pieces of code
1. Limits config
The numbers live in one table. A route can override the default; anything not listed uses default.
// src/ratelimit/limits.ts
export const LIMITS: Record<string, Limit> = {
default: { capacity: 60, refillPerSec: 1, cost: 1 },
"POST /search": { capacity: 20, refillPerSec: 0.5, cost: 1 },
"POST /export": { capacity: 5, refillPerSec: 0.05, cost: 5 },
};
capacityis the burst size and the cap on stored tokens.refillPerSecis the steady rate. It may be fractional: 0.05 is one token every 20 seconds.costis how many tokens one call takes. Export costs 5, so a full bucket allows exactly one export.
2. Building the key
// src/ratelimit/key.ts
export function bucketKey(req: Request): string {
const who = req.auth?.tenantId ?? req.ip;
const route = `${req.method} ${req.route.path}`;
return `rl:{${who}}:${route}`;
}
- Authenticated calls are limited per tenant, so every user in a company shares one bucket.
- Anonymous calls fall back to the IP address.
- The braces around
whoare a Redis Cluster hash tag. They keep all of one tenant's buckets on the same shard.
3. The Lua script
This is the heart of the limiter. It runs atomically on Redis, so no other command can interleave between the read and the write.
-- src/ratelimit/take.lua
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2]) -- tokens per second
local cost = tonumber(ARGV[3])
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
local h = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(h[1]) or capacity
local ts = tonumber(h[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * refill / 1000)
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 / refill * 1000) * 2)
local retry = 0
if allowed == 0 then retry = math.ceil((cost - tokens) / refill * 1000) end
return { allowed, math.floor(tokens), retry }
- Lines 1 to 3 read the limit. Nothing is hard-coded in Lua.
- Lines 5 and 6 take the time from Redis, not from the app server. App clocks drift; Redis has one clock.
- Lines 8 to 10 load the bucket. A missing key means a brand-new caller, who starts full.
- Line 12 is the refill: elapsed time times the rate, capped at capacity. Nobody runs a timer; refill is computed lazily on the next request.
- Lines 14 to 18 decide. Tokens are taken only when there are enough for the full cost.
- Line 21 sets the key to expire after twice the time needed to refill from empty. That is where the 120 s idle expiry comes from.
- Lines 23 and 24 work out how long until enough tokens exist. That becomes
Retry-After.
4. The middleware
// src/ratelimit/middleware.ts
export async function rateLimit(req: Request, res: Response, next: NextFunction) {
const limit = LIMITS[`${req.method} ${req.route.path}`] ?? LIMITS.default;
try {
const [allowed, left, retryMs] = await withTimeout(
redis.evalsha(TAKE_SHA, 1, bucketKey(req), limit.capacity, limit.refillPerSec, limit.cost),
25,
);
res.setHeader("X-RateLimit-Remaining", left);
if (!allowed) {
res.setHeader("Retry-After", Math.ceil(retryMs / 1000));
return res.status(429).json({ error: "rate_limited" });
}
} catch (err) {
metrics.increment("ratelimit.fail_open");
logger.warn({ err }, "rate limiter unavailable, allowing request");
}
next();
}
- Picks the route's limit, or the default.
- Calls the script by its SHA, with a 25 ms timeout so Redis trouble cannot stall the API.
- Returns 429 with
Retry-Afterin whole seconds when the script says no. - On any error or timeout it logs, bumps a metric and calls
next(). The limiter fails open.
Gotchas
- Idle buckets reset to full. The key expires after 120 s (twice the time to refill from empty). A caller who pauses for two minutes comes back with a full bucket.
EVALSHAcan fail with NOSCRIPT after a Redis failover or restart. The client reloads the script and retries once; if that also fails you hit the fail-open path.- Hash tags concentrate load.
{tenantId}pins all of a tenant's buckets to one shard, so one very large tenant makes a hot shard. - Anonymous traffic is limited by IP. Offices and mobile carriers share addresses, so legitimate users can be throttled together. Make sure
trust proxyis set, or every request appears to come from the load balancer. - Tokens are fractional inside Redis but
X-RateLimit-Remainingis floored. A header of 0 can still mean 0.9 of a token. - Cost greater than capacity can never succeed. Setting
cost: 5withcapacity: 4returns 429 forever and the script reports a retry time that never arrives.