What this module covers: Rate limiting as admission control — converting overload into a cheap, explicit, immediate rejection instead of an expensive collapse. The four algorithms and what each one actually permits: fixed window and its boundary burst, sliding window log and its memory bill, sliding window counter as the working compromise, and token bucket as the only one that expresses "sustained rate plus a burst allowance." Where the counter lives, and why a per-instance counter silently multiplies your limit by your fleet size. Correct Redis implementations, in one round trip, and the INCR-without-EXPIRE bug that produces a permanent lockout. What to key on, and why X-Forwarded-For is attacker-controlled until you know your proxy depth. Why request count is a poor proxy for cost, and when a concurrency limit is the right control instead. Quotas as a billing construct with different storage requirements. And the response contract — 429, Retry-After, and why publishing your limits reduces the load on you.
A Limiter Exists to Make Rejection Cheaper Than Collapse
Start with what happens without one. Your service can serve 4,000 requests per second before latency leaves its SLO. At 6,000 it does not serve 4,000 and reject 2,000 — it accepts all 6,000, queues them, and every request gets slower. Queues grow, p99 crosses client timeouts, clients retry, offered load rises to 9,000, and you have Module A-6's retry storm feeding on a service that is now doing more work than it did at the start while completing less of it. Throughput past the knee of the curve does not plateau, it declines.
A rate limiter is the decision to spend one cheap operation — a counter increment and a comparison — to refuse work you cannot do, so that the work you accept still completes inside its budget. Four distinct motives lead teams here, and they want different designs:
Motive
What you are protecting
Natural key
Natural limit
Capacity protection
your own latency SLO
global, or per-endpoint
derived from measured capacity
Fairness
one tenant's share of shared capacity
tenant / API key
proportional to plan
Cost control
your bill (Module O-8)
tenant, weighted by cost
budget ÷ unit cost
Abuse prevention
credential stuffing, scraping, enumeration
IP, account, endpoint
deliberately tight
Conflating them is the usual design error. A limit tuned to stop credential stuffing on /login (five attempts per account per fifteen minutes) applied to /api/orders is absurd; a limit sized for fairness across tenants does nothing about a single tenant whose plan legitimately allows enough traffic to saturate you. Most mature systems run several limiters at different scopes simultaneously, which is the multi-tier section below.
One boundary to draw immediately, because it is the most common category error in this area: rate limiting is not load shedding. A limiter enforces a policy — a number you decided in advance, usually contractual and published. Load shedding (Module A-8) reacts to your current state — queue depth, latency, saturation — and drops work you nominally promised to accept because accepting it would make things worse. You need both. A limiter with a limit set above your real capacity protects nothing; a shedder without a limiter has no way to distinguish the tenant who is inside their contract from the one who is not.
Analogy: a nightclub with a door policy. The capacity sign is load shedding: when the room is full, nobody else comes in regardless of who they are. The guest list with plus-ones is rate limiting: each named guest may bring a set number of people, decided before the evening, whether or not the room is busy. The bouncer turning someone away in two seconds is the cheap rejection — the alternative is letting everyone in until nobody can reach the bar, at which point the people already inside leave.
Four Algorithms, and What Each One Actually Permits
The algorithm choice is not academic: each one permits a different traffic shape at the same nominal limit, and the difference shows up as an incident.
Fixed window counter
Divide time into aligned windows — every minute on the minute — keep a counter per key per window, reject when it exceeds the limit.
text
Cheap — one integer per key — and simple to reason about, which is why it is everywhere. Its flaw is structural: a client that sends its full allowance at the end of one window and again at the start of the next delivers twice the limit in a short interval, and nothing in the algorithm is violated. If your limit was derived from real capacity, you have just admitted 2× capacity, and it recurs on a clock so your incident has a suspiciously regular shape.
There is a second, quieter problem: aligned windows synchronise clients. Every well-behaved client that got a 429 waits for the top of the minute, so they all resume in the same 50 ms. This is Module P-12's thundering herd generated by your own limiter.
Sliding window log
Store a timestamp per request. To decide, drop entries older than the window and count what remains.
ts
Perfectly accurate with no boundary artefact. The cost is memory proportional to the limit, per key: a 10,000-per-hour limit across 50,000 API keys is 500 million stored timestamps, and each is a sorted-set member with overhead, not an 8-byte integer. It also does more work per request than any other option. Use it where the limit is small and precision matters — login attempts, password resets, expensive report generation — and never for a high-volume general API limit.
Sliding window counter
Keep two fixed-window counters and interpolate: weight the previous window by how much of it still falls inside the sliding window.
text
Two integers per key, no clock-aligned doubling, and an error bounded by the assumption that the previous window's traffic was evenly distributed within it — which it was not, so the estimate can be wrong in either direction by a modest amount. This is the right default for a general-purpose API limit: it costs almost what fixed window costs and removes fixed window's actual failure mode. The trade is that it is approximate, so it cannot be the mechanism behind a contractual "exactly 10,000 per hour" claim.
Token bucket
A bucket of capacity B refills at R tokens per second. Each request takes one token (or several — see cost weighting); an empty bucket means rejection.
ts
Token bucket is the only one of the four that separates two things every real API needs to state separately: sustained rate (R) and burst allowance (B). A client that is idle for a minute can spend its accumulated tokens at once, which is usually what you want — a page load firing twelve parallel API calls is normal behaviour that a strict per-second limiter punishes for no reason. And it yields an exact Retry-After, because you can compute precisely when the next token arrives.
Its cost is that burst is real: B requests can arrive in a millisecond, so your capacity planning must survive B, not R. Choosing B far above R is how teams accidentally re-create the fixed-window doubling with extra steps.
Leaky bucket deserves a note, because the name is used for two different things. As a policing algorithm it is token bucket's mirror image and behaves equivalently. As a queue — requests enter a buffer and drain at a fixed rate — it is a traffic shaper rather than a limiter: excess requests wait instead of failing. Shaping is right for outbound traffic you control (your webhook delivery workers respecting a partner's limit, Module P-17) and wrong for inbound HTTP, where making a client wait converts your limiter into the latency problem it existed to prevent.
Memory/key
Boundary burst
Accuracy
Expresses burst?
Exact Retry-After
Fixed window
1 integer
2× limit
exact within window
no
window end only
Sliding log
O(limit)
none
exact
no
yes
Sliding counter
2 integers
none
approximate
no
approximate
Token bucket
2 numbers
bounded by B
exact
yes
yes
Default to token bucket for API limits where you want to be generous about bursts and strict about sustained rate, sliding window counter where you want a simple published number, and sliding log only for small, security-sensitive limits.
A Per-Instance Counter Multiplies Your Limit by Your Fleet Size
This is the design decision that matters more than the algorithm, and it is frequently made by accident — by installing a middleware whose default store is in-process memory.
Run 40 instances behind a load balancer with a 100-per-minute in-memory limiter and your actual limit is somewhere between 100 and 4,000 per minute, depending on how the load balancer distributes that client's requests. Round-robin makes it nearly 4,000. It gets worse: the effective limit now changes when you autoscale, so the same client is throttled differently at 3 a.m. and at peak, and your limit is a function of your deployment rather than of your policy.
Where the counter lives
Accuracy
Latency added
Failure coupling
When it is right
In-process memory
limit × instances
none
none
single instance; or a deliberate per-instance concurrency cap
Central Redis
exact
one round trip (~0.5 ms same-AZ)
limiter depends on Redis
the default for a fleet
Local counters, async aggregation
approximate, converges
none on the request path
none
very high volume where approximation is acceptable
Edge / CDN / gateway
exact per PoP, approximate globally
none to your origin
none
abuse prevention; cheapest rejection possible
Central Redis is the standard answer and its costs must be stated. It adds a network round trip to every request, which for a 5 ms endpoint is 10% of your latency budget — so the limiter must be one round trip, not four. It makes a limiter key a hot key: a single enormous tenant's counter is one Redis key handled by one shard, and Module P-7 explains why you cannot spread it. And it couples your availability to Redis, which forces a decision you should make deliberately:
Fail open or fail closed when Redis is unreachable? Fail open (allow the request) keeps you serving during a Redis incident and removes your protection at the moment traffic is most likely to be abnormal. Fail closed (reject) turns a Redis outage into a total outage. The defensible position is per-motive: fail open for fairness and quota limiters, fail closed for abuse limiters on /login and password reset, and keep an in-process fallback limiter with a generous per-instance limit so failing open is not unlimited. Whichever you choose, choose it explicitly — the default in most libraries is fail open, silently.
Do It in One Round Trip, and Never INCR Without Guaranteeing the Expiry
The obvious Redis implementation contains a bug that produces a permanent lockout:
ts
If the process is killed between the two commands — a deploy, an OOM kill, a reclaimed spot instance — the key exists with no TTL. It never resets. That client is rejected forever, and no amount of waiting helps. The symptom is one tenant reporting permanent 429s that no restart fixes, and the cause is invisible unless someone thinks to run TTL on the key.
Two commands are also two round trips, doubling the latency the limiter adds. Both problems have the same fix: make the whole decision one atomic server-side operation with a Lua script.
lua
Three properties make this the version to ship. It is atomic, so concurrent requests for one key cannot both read the same token count. It is one round trip, so it costs one RTT rather than four. And the PEXPIRE is unconditional on every call, so there is no window in which a key can exist without a TTL — with the TTL set to the time it takes an idle bucket to refill completely, since after that the state is indistinguishable from a fresh bucket and can be discarded safely.
Two operational notes. Use EVALSHA with the script cached, not EVAL shipping the source on every request. And under Redis Cluster, all keys touched by one script must live on one node, so if you ever need a multi-key script use a hash tag — limit:{tenant42}:api and limit:{tenant42}:reports share a slot deliberately (Module P-7).
Why this matters in production: a limiter is on the hot path of every request, so its own failure modes are outages. The three that actually happen are the TTL-less key that locks a customer out permanently, the four-round-trip implementation that adds 2 ms of p50 to every request in the system, and the limiter that fails closed during an unrelated Redis blip and takes the whole API down with it. None of the three is about choosing the wrong algorithm.
What You Key On Decides Who Gets Punished
The identity you count against determines who bears the cost of a mistake, and the ordering is not a matter of taste.
API key or tenant ID, when you have one. It is stable, it maps to a contract and a plan, it survives a client changing networks, and it makes the limit a documented number rather than a surprise.
Authenticated user ID for user-facing APIs, so one user on a shared corporate network cannot exhaust the allowance of the four hundred colleagues behind the same egress address.
IP address only as a last resort, for unauthenticated traffic where nothing better exists — and with clear eyes about two problems. First, IPs are shared: carrier-grade NAT puts thousands of mobile users behind one address, and a corporate office or a university is one address for an entire building. A tight per-IP limit is an outage for a large customer. Second, IPv6 makes per-address limiting nearly useless because a single subscriber commonly has a /64 — 18 quintillion addresses to rotate through — so limit on the /64 prefix rather than the address.
And the trap that turns an IP limiter into no limiter at all:
ts
X-Forwarded-For is an append-only list, and an attacker can prepend anything. Taking the leftmost value means the attacker chooses their own limiter key and can send a different one per request. The correct read is from the right, skipping exactly as many entries as you have trusted proxies:
ts
Get the count wrong in the other direction and you key everything on your own load balancer's address, limiting all traffic globally as if it were one client. Both errors are silent. If you sit behind a CDN, prefer its verified header (CF-Connecting-IP, True-Client-IP) and verify the request actually came from the CDN's address range, or the header is as forgeable as the one it replaces.
For abuse prevention specifically, key on a combination and limit each: /login needs a per-account limit (stops distributed password spraying against one account), a per-IP limit (stops one host trying many accounts), and a global limit (stops a botnet doing both slowly). Any single dimension is bypassable by varying the other.
Counting Requests Assumes Requests Cost the Same, and They Do Not
A limit of 1,000 requests per minute treats a primary-key lookup and a twelve-month aggregation report as equivalent. Module P-8 made this point as the mechanism behind a specific outage: one tenant, entirely inside their rate limit, took the shared database down with expensive queries, because request count is a poor proxy for cost.
Two fixes, used together.
Weight the cost. Token bucket already supports it — a request takes cost tokens, not one. Publish a table:
Endpoint
Cost
Rationale
GET /orders/:id
1
indexed point read
GET /orders?limit=100
5
index scan plus serialisation
POST /reports/revenue
50
aggregation over months of rows
POST /exports
200
full table scan, writes a file
For GraphQL the equivalent is query complexity analysis, and it is not optional — Module F-8's point that one GraphQL request can be arbitrarily expensive means a per-request limit is meaningless there. Compute a static cost from the query's depth, breadth and requested page sizes, reject queries above a ceiling, and charge the computed cost.
Limit concurrency, not just rate, for expensive work. This is the control teams reach for last and often need most. Little's Law (Module F-4) says concurrency = arrival rate × service time, so a rate limit only bounds resource usage if service time is bounded — and for a report that sometimes takes 200 ms and sometimes 90 seconds, it is not. A limit of "at most 2 concurrent exports per tenant" bounds the resource directly, and the tenant's own throughput self-adjusts to whatever their queries actually cost.
ts
Note the TTL on the concurrency key. A finally block does not run when the process is killed, so every concurrency limiter needs a lease that expires — the identical lesson to Module P-16's expired idempotency claim, and the same failure if you skip it: a tenant permanently at their concurrency ceiling with nothing running.
This is also the correct place to enforce the per-tenant connection budget from Module P-8. A cap on concurrent database work per tenant, which rejects rather than queues, is what stops one tenant holding every connection in the pool while everyone else waits (Module P-4).
Quotas Are a Billing Construct and Need Different Storage
A rate limit and a quota look alike and have almost nothing in common operationally.
Rate limit
Quota
Period
seconds to minutes, rolling
billing period — a month, aligned to the plan
Purpose
protect capacity, ensure fairness
meter and monetise consumption
Storage
ephemeral; losing it is a minor fairness blip
durable; losing it is a revenue and dispute problem
Exceeded
429, retry shortly
402/403, upgrade or wait for the reset; or overage billing
Source of truth
Redis
the database, with Redis as a fast path
Reconciliation
not needed
required — it appears on an invoice
The consequence is that a quota counter cannot live only in Redis, for the reason Module P-16 gave about dedupe: an eviction or a failover loses writes, and here the lost writes are money. The workable design counts in Redis for the fast decision and treats an append-only usage table in Postgres as the record, aggregated periodically:
sql
The temptation to skip this and keep a tenants.calls_this_month column that every request updates deserves an explicit warning: a single row updated on every request serialises all of that tenant's traffic on one row lock, and generates a dead tuple per update, which is Module F-13's bloat mechanism at maximum intensity. It is one of the few designs that is both a correctness bottleneck and a vacuum problem at once.
Two quota behaviours worth deciding in advance rather than during an incident: whether exceeding a quota blocks (hard limit, predictable cost, angry customer mid-launch) or bills as overage (soft limit, happy customer, occasional enormous invoice and a dispute); and whether the reset is calendar-aligned or anniversary-aligned, since calendar alignment means every tenant's quota resets at the same instant and Module P-12's thundering herd arrives at midnight on the first.
The Response Is Part of the Design, Because It Shapes the Retry
A limiter that rejects badly creates the load it was built to prevent. Three requirements.
Return 429 Too Many Requests, with Retry-After. Not 503 (which means you are broken, and instructs well-built clients to retry against another node), not 403 (which means never), not 400. 429 plus Retry-After tells a client exactly one thing — wait this long — and a client that obeys it stops guessing.
http
Publish the state on successful responses too.RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset on every response let a well-behaved client pace itself and slow down before it is rejected. This is genuinely in your interest: a client that can see it has 20 of 1,000 remaining spreads its work out, whereas a client discovering the limit only through rejections learns it by hammering you. Token bucket makes these values exact, which is a real argument for it. The cost is a small information leak — the numbers tell an attacker how much room they have — which matters for abuse limiters, so omit the headers there and return a bare 429.
Make rejection genuinely cheap. A 429 that goes through connection setup, authentication middleware, an ORM initialisation and a template render costs you nearly as much as serving the request, so the limiter has protected nothing. Reject as early in the stack as possible — at the CDN or gateway for abuse, in the first middleware for everything else, before any database access. And never log a full line per rejection: a client sending 50,000 rejected requests a second will fill a disk and cost more in log ingestion than the requests would have cost to serve, which is Module O-1's cardinality warning arriving from an unexpected direction.
A related client-side obligation, since you are also somebody's client: honour Retry-After instead of applying your own backoff curve on top of it, and keep a retry budget (Module A-6) so that a service returning 429 to everything does not receive three times the traffic as a result.
Layer the Limits, and Derive the Numbers From Measurement
Real systems run several limiters at once, each answering a different question:
Tier
Key
Typical limit
Prevents
Global
none
measured capacity minus headroom
total overload; the last line before shedding (Module A-8)
Per tenant
API key
plan-based
one customer consuming the shared pool
Per endpoint per tenant
key + route
cost-weighted
expensive endpoints from dominating
Concurrency per tenant
key + resource
small integer
unbounded-duration work
Abuse
account + IP + global
deliberately tight
credential stuffing, scraping, enumeration
Evaluate cheapest-first and reject on the first violation, so an abusive client is rejected by an in-memory or edge check rather than after five Redis round trips.
The last question is where the numbers come from, and the honest answer is that a limit invented in a design meeting is a guess. It should come from a load test that found the knee of your curve (Module O-5), minus headroom, divided across tenants by plan. A limit above your real capacity provides no protection and creates a contractual promise you cannot keep — which is worse than no limit, because customers build against it. A limit far below capacity throttles paying customers to protect capacity you are already funding (Module O-8). Both errors are found the same way: measure, then set the number, then re-measure after every significant change to what the endpoint does.
Where This Shows Up in Your Stack
Redis: the canonical home for limiter state — small keys, extremely high write rate, no durability requirement, and EVALSHA giving you atomic multi-step logic in one round trip. Keep the whole decision in one script; never INCR then EXPIRE as two commands. Watch for the hot-key problem when one tenant's counter is enormous (Module P-7), use hash tags if a script must touch several keys under Cluster, and remember that maxmemory-policy matters: an allkeys-lru instance can evict a limiter key and silently reset a client's usage, so limiter state generally belongs on an instance with volatile-ttl or on its own database.
PostgreSQL: the durable record for quotas, as an append-only usage_events table with a uniqueness constraint for dedupe (Module P-16), aggregated on a schedule. Never a single counter column updated per request — that serialises the tenant on one row lock and produces a dead tuple every time (Module F-13). Partition usage by month so closing a billing period and dropping old data are both metadata operations (Module P-9).
Node.js:express-rate-limit and friends default to an in-memory store, so the deployed limit is your configured limit times your instance count — always configure the Redis store explicitly. Set app.set("trust proxy", n) to your actual proxy depth or req.ip is either attacker-controlled or globally identical. Because the event loop is single-threaded, a concurrency limiter also protects you from one tenant's heavy JSON serialisation blocking every other request on the instance.
Nginx / CDN / API gateway:limit_req is a leaky bucket with an optional burst, and it is the cheapest possible rejection because it never reaches your application. Cloudflare, CloudFront and equivalents give you per-PoP counting — approximate globally, exact enough for abuse prevention, and free of any round trip. Put abuse limits here and business limits in the application, where the tenant and plan are known.
Kafka: broker-side quotas throttle producer and consumer byte rates per client ID, which is the same fairness argument one layer down: without them, one misconfigured producer saturates the brokers and every consumer group's lag climbs (Module P-15).
Summary
A limiter exists to make rejection cheaper than collapse. Past the knee of the curve, offered load does not plateau — accepted work slows, clients time out and retry, and throughput declines while load rises. One counter increment refuses the work you cannot do so the work you accept still completes.
Rate limiting is policy; load shedding is reaction. A limiter enforces a number you published in advance; a shedder (Module A-8) drops work because of your current queue depth and latency. You need both, and a limiter set above your real capacity protects nothing.
Fixed window permits 2× the limit across a boundary and synchronises every throttled client onto the same instant. Sliding log is exact but costs memory proportional to the limit per key. Sliding window counter is two integers and removes the boundary burst at the price of approximation. Token bucket is the only one that expresses sustained rate and burst allowance separately, and the only one that yields an exact Retry-After — at the cost that a burst of B is real and your capacity plan must survive it.
An in-memory counter multiplies your limit by your instance count, and changes when you autoscale, so the effective limit becomes a function of your deployment rather than your policy. Central Redis is the default; it adds a round trip, creates hot keys for large tenants, and couples your availability to Redis — so decide fail-open versus fail-closed deliberately, per motive, with a generous in-process fallback so failing open is not unlimited.
Never INCR then EXPIRE as two commands. A process killed between them leaves a key with no TTL and locks that client out permanently, with no symptom other than 429s that no restart clears. One Lua script, EVALSHA, expiry set unconditionally on every call, one round trip.
Key on API key or user ID; use IP only as a last resort. CGNAT and corporate egress put thousands of users behind one address, IPv6 requires limiting the /64 prefix, and the leftmost X-Forwarded-For value is attacker-controlled — set your actual trusted-proxy depth, or verify your CDN's header and source range. Abuse limits need per-account and per-IP and global dimensions, because any single one is bypassable.
Request count is a poor proxy for cost (Module P-8): weight expensive endpoints in token cost, compute query complexity for GraphQL, and for work of unbounded duration limit concurrency rather than rate — because Little's Law means a rate limit only bounds resources when service time is bounded. Concurrency permits need a TTL, or a killed process leaks one forever.
Quotas are durable and rate limits are not. A quota appears on an invoice, so its record belongs in an append-only Postgres table with Redis as the fast path — never a single counter column updated per request, which serialises the tenant on a row lock and bloats the table at once.
Reject with 429 and Retry-After, publish RateLimit-* headers on success so clients pace themselves before being rejected rather than discovering the limit by hammering you — omitting them on abuse limiters, where the numbers help an attacker. Make the rejection genuinely cheap and early, and never log a line per rejection or the log bill exceeds the cost of serving the traffic.
Layer limits cheapest-first — global, per tenant, per endpoint, concurrency, abuse — and derive the numbers from a load test (Module O-5), not a design meeting. A limit above capacity is a promise you cannot keep; a limit far below it throttles customers to protect capacity you are already paying for.
The next module, Background Jobs and Scheduling, is where the work you accepted but did not do inline actually happens. Everything deferred so far — the webhook receiver's queue-then-ack, the export that needed a concurrency limit instead of a rate limit, the outbox relay — lands in a worker pool with its own retry policy, priority classes, and the cron job that fires twice on the same minute.
Knowledge Check
A team runs 40 application instances behind a round-robin load balancer and enforces "100 requests per minute per API key" with a middleware whose default store is in-process memory. A customer complains that they are sometimes throttled at roughly 100 requests and sometimes not until several thousand, and that the behaviour changed after an autoscaling event. What is the diagnosis?
One tenant on a plan allowing 1,000 requests per minute repeatedly takes the shared database to its knees without ever exceeding that limit. Their traffic is mostly calls to a revenue-report endpoint whose runtime varies from 200 ms to 90 seconds depending on the range requested. What does the module prescribe?
A limiter uses INCR followed by EXPIRE when the counter is 1. After a rolling deploy, one customer reports being rejected with 429 continuously, and it does not clear after an hour, a day, or an application restart. Every other customer is fine. What happened, and what is the fix?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
limit = 100/min
|<--- 12:00:00–12:00:59 --->|<--- 12:01:00–12:01:59 --->|
100 reqs | 100 reqs
^^^^^^^^^^^^
200 requests in ~2 seconds, both windows legal
// Exact, and its cost is visible in the data structure.await redis.zremrangebyscore(key,0, now - windowMs);// expire oldconst count =await redis.zcard(key);if(count >= limit)returnreject();await redis.zadd(key, now,`${now}:${crypto.randomUUID()}`);// member must be unique
now = 12:01:15, window = 60s → 75% of the previous minute is still in range
estimate = previous_count * 0.75 + current_count
// Two numbers per key: token count and last refill time. Refill is lazy arithmetic,// not a background job — you compute it when you look.const elapsed =(now - state.lastRefill)/1000;const tokens = Math.min(capacity, state.tokens + elapsed * refillRate);if(tokens < cost)returnreject({ retryAfter:(cost - tokens)/ refillRate });save({ tokens: tokens - cost, lastRefill: now });
// BROKEN. Two commands, and the second may not run.const n =await redis.incr(key);if(n ===1)await redis.expire(key,60);// if the process dies here, key is eternalif(n > limit)returnreject();
-- token_bucket.lua — KEYS[1] state hash; ARGV: capacity, refill/sec, cost, now(ms)local cap, rate, cost, now =tonumber(ARGV[1]),tonumber(ARGV[2]),tonumber(ARGV[3]),tonumber(ARGV[4])local s = redis.call('HMGET', KEYS[1],'tokens','ts')local tokens =tonumber(s[1])or cap
local ts =tonumber(s[2])or now
tokens = math.min(cap, tokens +((now - ts)/1000)* rate)-- lazy refilllocal allowed = tokens >= cost
if allowed then tokens = tokens - cost endredis.call('HSET', KEYS[1],'tokens', tokens,'ts', now)redis.call('PEXPIRE', KEYS[1], math.ceil((cap / rate)*1000)+1000)-- always set, every callreturn{ allowed and1or0, math.floor(tokens), math.ceil(((cost - tokens)/ rate)*1000)}
// BROKEN: the client controls this header.const ip = req.headers["x-forwarded-for"]?.split(",")[0]?? req.ip;
app.set("trust proxy",2);// number of proxies you actually operate; then req.ip is correct
// The counter is decremented in `finally`, or a crashed request leaks a permit forever.const permit =await redis.eval(ACQUIRE,1,`conc:${tenant}:export`,2, ttlSeconds);if(!permit)return res.status(429).set("Retry-After","5").json({ error:"too_many_concurrent"});try{returnawaitrunExport();}finally{await redis.decr(`conc:${tenant}:export`);}
-- Append-only. One row per unit of usage, or per small batch.CREATETABLE usage_events ( tenant_id bigintNOTNULL, metric textNOTNULL,-- 'api_call' | 'export' | 'tokens' quantity bigintNOTNULL, occurred_at timestamptz NOTNULLDEFAULTnow(), request_id textNOTNULL,-- for dedupe, Module P-16UNIQUE(tenant_id, metric, request_id));
HTTP/1.1429Too Many RequestsRetry-After:3RateLimit-Limit:1000RateLimit-Remaining:0RateLimit-Reset:3Content-Type:application/json{"error":"rate_limited","limit":1000,"window":"1m","retry_after":3}