One Tuesday morning, the Aroma Store API starts answering in four seconds. There is no new deployment, no error in the logs, the database is not down. Looking at the traffic, the culprit appears: a single IP address making 400 requests per second to GET /v1/coffees?limit=100, walking the entire catalogue every few seconds. This is not a sophisticated attack: it is somebody copying the catalogue or — just as likely — a client with a badly written useEffect retrying in a loop without waiting.
The API is authenticated, authorised and hardened. And even so, anyone can bring it down simply by calling it a lot. This lesson closes the last open row of the threat model from 04-02: API4, unrestricted resource consumption. We will see why every public API needs limits, the four classic algorithms with their trade-offs, what is used as the grouping key, the 429 response with its headers, the implementation in the project with express-rate-limit and Redis, the other availability defences, and what a well-behaved client should do when it is told to stop.
Warning. Rate limiting is an availability defence and, badly tuned, it can deny service to legitimate users or let an attack through. The values in this lesson are a reasonable starting point for a fictional case; a real deployment must size them with its own data and review them with a security professional.
Contents
- Why every public API needs limits
- Rate limiting, throttling and quotas
- The four algorithms
- An annotated token bucket implementation
- The key: what you count against
- The IP problem: NAT, proxies and
trust proxy - Limits per endpoint
- Aroma Store's tiers
- The 429 response and its headers
- Implementation:
src/middleware/rate-limit.js - In-memory store versus Redis
- Other availability defences
- 503,
Retry-Afterandservice_unavailable - What a well-behaved client does
- How limits are communicated in the documentation
- Where rate limiting lives in production
- Testing the limit with
node:test
- Why every public API needs limits
There are five reasons and they are worth distinguishing, because each one calls for a different limit:
| Reason | Example in Aroma Store | Which limit stops it |
|---|---|---|
| Deliberate abuse | Brute force against POST /v1/sessions |
Very strict, per IP and per email address |
| Scraping | Copying the whole catalogue every hour | Moderate, per IP on reads |
| Badly programmed clients | A retry loop with no wait | Moderate, with a clear Retry-After |
| Cost | Expensive searches that blow up the bill | A specific limit on ?q= |
| Fairness | One consumer eats 95% of the capacity | A limit per authenticated consumer |
The third one deserves an important nuance: most abusive traffic is not malicious. It is a developer who has not noticed that their retry has no wait, a mobile app that refreshes on every scroll, a cron job that overlaps with itself. That changes the design of the response: the 429 has to be pedagogical, say when to come back and be easy to handle programmatically, because its usual recipient is somebody who wants to fix it.
And there is a cross-cutting reason that encompasses them all: protecting the database. Your API can scale to more instances; Aroma Store's SQLite, or the PostgreSQL that comes after it, not so much. Rate limiting is what stops a traffic spike turning into an avalanche of queries that leaves every other consumer without service.
- Rate limiting, throttling and quotas
Three terms used as synonyms which are not:
| Concept | What it does | Horizon | Typical response |
|---|---|---|---|
| Rate limiting | Rejects whatever exceeds a rate | Seconds or minutes | An immediate 429 |
| Throttling | Delays rather than rejecting | Seconds | A slower 200, or a queue |
| Quota | The total allowed in a long period | A day or a month | 429 or 402 on exhaustion |
- Rate limiting: "100 requests per minute". The 101st is rejected.
- Throttling: "at most 10 concurrent requests". The 11th waits in a queue. It is friendlier to the client but consumes your resources while it waits, and it can degrade the whole system if the queue grows: that is why it always needs a queue cap and a timeout.
- Quota: "50,000 requests a month on the free plan". It is a commercial concept more than a technical one; it shows up on the invoice, not in every response.
Aroma Store uses rate limiting as its main mechanism, with a daily quota for the partner SwiftShip. Throttling appears naturally in one place: the database connection pool, which already limits real concurrency.
- The four algorithms
Fixed window
You count how many requests there are in the current minute; when the minute changes, the counter goes back to zero.
It is trivial to implement and very cheap: one integer per key. Its problem is the boundary effect:
10:00:59 → 100 requests (allowed: the 10:00 window was at 0) 10:01:00 → 100 requests (allowed: a new window) ──────────────────────────── 200 requests in 1 second with a limit of "100 per minute"
Twice the limit in an instant. With generous limits that is acceptable; for protecting a login, it is not.
Sliding window log
You store the timestamp of every request and count those from the last 60 seconds, exactly. It is 100% precise and has no boundary effect. The cost is memory: with a limit of 1,000 per minute and 50,000 active keys, that is 50 million timestamps.
Sliding window counter
The practical compromise. Two counters are kept — the current window's and the previous one's — and the sliding value is estimated by interpolation:
// 15 s into the current window, 25% of it has been consumed,
// so the previous window still carries 75% of the weight.
const previousWeight = 1 - elapsedInWindow / windowDuration; // 0.75
const estimated = previousCount * previousWeight + currentCount;With two numbers per key the boundary effect is practically eliminated. This is what most serious implementations use, Cloudflare included.
Token bucket
A bucket with capacity C that is refilled at R tokens per second. Every request consumes a token; if there is none, it is rejected.
Its charm is that it allows bursts in a controlled way: a client that has been quiet accumulates tokens up to C and can spend them all at once, but its sustained rate never exceeds R. That fits an API's real traffic very well: an app opening a screen makes eight requests in a row and then goes quiet for a minute, and we do not want to punish it.
Leaky bucket
Requests enter a queue that drains at a constant rate. It smooths outgoing traffic completely, but it allows no bursts and it adds latency: it is throttling, not rate limiting. It is used more to regulate output towards a downstream system (calls to the payment gateway, for instance) than for input.
Comparison
| Algorithm | Precision | Memory per key | Complexity | Bursts | When to use it |
|---|---|---|---|---|---|
| Fixed window | Low (2× at the boundary) | 1 integer | Very low | Uncontrolled at the boundary | Generous limits, prototypes |
| Sliding window log | Perfect | N timestamps | Medium | No | Critical endpoints with few keys |
| Sliding window counter | Very high | 2 integers | Medium | Almost none | General use |
| Token bucket | High | 2 numbers | Medium | Yes, bounded | APIs with bursty traffic |
| Leaky bucket | High | A queue | High | No (it adds latency) | Regulating output towards third parties |
Aroma Store's choice: a token bucket for the general limit — because the SPA's and Aroma Mobile's traffic is naturally bursty — and a strict sliding window for POST /v1/sessions, where we want no bursts at all.
- An annotated token bucket implementation
// src/middleware/token-bucket.js (didactic: in production we use express-rate-limit)
/**
* Token bucket.
*
* @param {number} capacity Maximum accumulable tokens = the burst size allowed.
* @param {number} rate Tokens replenished per second = the sustained rate.
*/
export function createBucket(capacity, rate) {
// Stored per key: { tokens, lastRefill }.
// The central trick: there is NO timer. The tokens are computed when they are
// queried, from the elapsed time. That makes the algorithm O(1)
// and avoids keeping thousands of intervals alive.
const buckets = new Map();
function consume(key, cost = 1) {
const now = Date.now();
const bucket = buckets.get(key) ?? { tokens: capacity, lastRefill: now };
// 1. Lazy refill: tokens earned since the last query.
const seconds = (now - bucket.lastRefill) / 1000;
bucket.tokens = Math.min(capacity, bucket.tokens + seconds * rate);
bucket.lastRefill = now;
// 2. Is there enough for this request?
const allowed = bucket.tokens >= cost;
if (allowed) bucket.tokens -= cost;
buckets.set(key, bucket);
// 3. When there will be enough tokens, in seconds (for Retry-After).
const missing = Math.max(0, cost - bucket.tokens);
const waitSeconds = allowed ? 0 : Math.ceil(missing / rate);
return {
allowed,
remaining: Math.floor(bucket.tokens),
waitSeconds,
};
}
// 4. Clean-up: without this, memory grows without bound with every new IP.
// A full bucket is indistinguishable from one that does not exist, so it can be dropped.
function cleanUp() {
const now = Date.now();
const secondsToFill = capacity / rate;
for (const [key, bucket] of buckets) {
if ((now - bucket.lastRefill) / 1000 > secondsToFill) buckets.delete(key);
}
}
const timer = setInterval(cleanUp, 60_000);
timer.unref(); // does not stop the process from exiting
return { consume, cleanUp };
}Four details that make this work and that are almost always got wrong:
The lazy refill (step 1) is the key idea. One bucket per client each with its own setInterval would be unworkable with thousands of keys; computing the tokens when they are queried gives the same result at constant cost.
Math.min(capacity, ...) stops a client that has been idle for a day accumulating 86,400 tokens and pulling the entire database down in one go. The maximum accumulable amount is the capacity, and that is why the capacity is the burst size allowed.
The parameterisable cost (step 2) opens the door to the cost-per-query idea from section 12: an expensive search can consume five tokens instead of one.
The clean-up (step 4) is an availability requirement, not an optimisation: without it, an attacker rotating IP addresses grows the Map until the process runs out of memory. And unref() stops the timer keeping the process alive at shutdown, something that would break the graceful shutdown from 03-07 and the tests from 03-08.
- The key: what you count against
A limit is always applied to a group. Choosing the key badly is the most expensive mistake:
| Key | Advantages | Drawbacks | Use in Aroma Store |
|---|---|---|---|
| IP | Works without authentication | NAT groups thousands together; IPv6 allows rotation; proxies | Anonymous traffic and login |
Authenticated user (sub) |
Fair and precise | Only after authenticating | Authenticated traffic |
OAuth client_id |
Isolates CataBox from the SPA | Only with OAuth (04-03) | Third parties |
| API key | The same, for partners | They have to be issued and rotated | SwiftShip |
| A combination | The best of each | More state | What we use |
Aroma Store's selection rule, in order of preference:
// src/middleware/rate-limit.js (fragment)
export function rateLimitKey(req) {
// 1. An identified third-party application: it is limited, not the user.
if (req.user?.oauthClient) return `oauth:${req.user.oauthClient}:${req.user.id}`;
// 2. An authenticated user: the fairest key.
if (req.user?.id) return `usr:${req.user.id}`;
// 3. Anonymous: there is no alternative to the IP.
return `ip:${req.ip}`;
}Case 1 deserves an explanation: if CataBox has a bug and hammers the API on behalf of 500 users, limiting per user stops nothing — those are 500 different keys. The composite key also allows an aggregate cap per client_id, which is what really protects you.
- The IP problem: NAT, proxies and
trust proxy
trust proxyLimiting by IP has three serious problems you need to know about before settling on a number:
NAT. An office, a university or a mobile operator can present hundreds or thousands of users behind a single public IP. A limit of 60 per minute per IP leaves an entire company without service if its employees use the shop. That is why the anonymous limit must be generous, and the strict one reserved for authenticated traffic or for specific operations.
IPv6. An attacker with a /64 prefix has trillions of addresses at their disposal. Limiting by individual IPv6 address is useless: you have to group by prefix (usually /64).
Proxies. This is the one that breaks the code. If your API sits behind a load balancer or a CDN — and in production it will — req.ip is the proxy's IP, not the client's. All the traffic shares one key and the first user exhausts everybody's limit.
The real IP arrives in X-Forwarded-For, a header containing the chain of hops:
And here is the trap: anyone can write that header. An attacker sends X-Forwarded-For: 1.2.3.4 and, if you trust it blindly, changes identity on every request and defeats the limit.
// src/app.js
// ❌ DANGEROUS: it trusts the whole chain, including the part the client wrote.
app.set('trust proxy', true);
// ✅ Trust exactly 1 hop: the load balancer YOU control.
// Express then takes the second-to-last entry of X-Forwarded-For, the one
// your own proxy wrote and the client cannot forge.
app.set('trust proxy', 1);
// ✅ An explicit alternative: the list of trusted proxies.
app.set('trust proxy', ['10.0.0.0/8', '172.16.0.0/12']);The number must match exactly the quantity of proxies in front. If you put 1 and there are two (CDN + load balancer), you get the first proxy's IP instead of the client's; if you put 2 and there is one, you get whatever IP the client fancies. Check it in the real environment before trusting it:
// A temporary diagnostic endpoint. It is removed afterwards: it exposes network information.
app.get('/diagnostics/ip', (req, res) => {
res.json({ ip: req.ip, ips: req.ips, xff: req.get('X-Forwarded-For') });
});Remember too that this same setting affects req.protocol and therefore the Secure cookies and the HTTPS redirects from 04-02: it is a configuration with consequences beyond rate limiting.
- Limits per endpoint
A uniform global limit is always a bad compromise: too lax for the login, too strict for the catalogue. Limits are tuned to the cost and risk of each operation.
| Endpoint | Cost | Risk | Limit | Reason |
|---|---|---|---|---|
GET /v1/coffees |
Low (cacheable) | Scraping | Generous | It is the shop window |
GET /v1/coffees?q=... |
High (a search) | Cost | Strict | An expensive, uncached query |
POST /v1/sessions |
Low | Brute force | Very strict | Account defence |
POST /v1/customers (registration) |
Medium (bcrypt, email) | Junk accounts | Strict | bcrypt costs CPU on purpose |
POST /v1/orders |
High (a transaction) | Cost, stock | Moderate | Protects the database |
POST /v1/orders/{id}/payment |
High (a third party) | Fraud, cost | Strict | Every attempt costs money |
POST /v1/coffees/{id}/image |
Very high | Storage | Very strict | Uploads |
GET /v1/orders |
Medium | Enumeration | Moderate | Authenticated |
The login case deserves an important clarification: you have to limit by two keys at once.
- By IP: stops whoever tries thousands of passwords against many accounts from one place.
- By target email address: stops distributed credential stuffing, where each attempt against
[email protected]comes from a different IP (a botnet). The per-IP limit sees nothing; the per-account limit does.
And a nuance that gets forgotten: only failed attempts are counted. If you also count the successful ones, a legitimate user who logs in and out several times ends up locked out. express-rate-limit supports this with skipSuccessfulRequests: true.
Beware also of the side effect of the per-account limit: if you lock the account for 15 minutes after five failures, an attacker can deny service to a specific user by failing on purpose. Mitigations: a progressive delay instead of a hard lockout, a short window, and not locking if the request comes from an IP that has already logged into that account successfully before.
- Aroma Store's tiers
| Tier | Who | Reads | Writes | Login | Daily quota |
|---|---|---|---|---|---|
| Anonymous | No token (public catalogue) | 60/min per IP | — | 5/15 min per IP and account | — |
| Authenticated customer | customer |
300/min | 60/min | — | 50,000 |
| Third-party application | CataBox (client_id) |
600/min aggregated | 60/min | — | 100,000 |
| Partner | SwiftShip (partner) |
1,000/min | 300/min | — | 500,000 |
| Internal back office | employee, administrator |
2,000/min | 600/min | — | No quota |
Search ?q= |
Anyone | 20/min | — | — | — |
Notes on these numbers, which matter more than the numbers themselves:
- The values are a starting point. The correct procedure is to measure the 99th percentile of real legitimate use and set the limit comfortably above it. A limit that cuts off real users costs more than one that is too generous.
- Start in observation mode.
express-rate-limitlets you count without blocking (askipthat only logs). Two weeks of data say far more than any estimate. - The tier is derived from the token, so role-specific rate limiting must go after authentication. The global anonymous limit, by contrast, goes before: it has to protect you even from whoever sends junk tokens.
- The 429 response and its headers
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 37
Aroma-RateLimit-Limit: 60
Aroma-RateLimit-Remaining: 0
Aroma-RateLimit-Reset: 1786000437
Aroma-Trace-Id: trc_9f3a2b7c
{
"error": {
"code": "rate_limit_exceeded",
"message": "You have exceeded the request limit. Try again in 37 seconds.",
"details": []
}
}| Header | Value | Meaning |
|---|---|---|
Retry-After |
37 |
Seconds to wait. The most important one: it is standard and libraries read it |
Aroma-RateLimit-Limit |
60 |
Requests allowed in the window |
Aroma-RateLimit-Remaining |
0 |
How many are left |
Aroma-RateLimit-Reset |
1786000437 |
The Unix instant at which it resets |
Four design decisions behind that response:
Retry-After always. Without it, the polite client does not know how long to wait and the impolite one retries immediately, making the problem worse. It accepts seconds or an HTTP date; seconds are easier and do not depend on the client's clock.
The informational headers on every response, not just on the 429. Their value lies in letting the client slow down before it hits the wall: if it sees Remaining: 3, it spaces its requests out. Sending them only on failure wastes the mechanism.
The Aroma- prefix, consistent with the contract (never X-, as we decided in 02-05). There is an IETF draft standardising RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset — and a combined form RateLimit: limit=60, remaining=0, reset=37. When it is published as an RFC it will be worth emitting both for a while and documenting the migration; meanwhile, our own prefix avoids collisions with whatever intermediaries may do.
The catalogue's rate_limit_exceeded code, with the same error format as everything else. A 429 that returns plain text breaks clients that parse errors.
And a CORS warning that is expensive to learn the hard way: the SPA cannot read any of those headers from JavaScript unless they are declared in Access-Control-Expose-Headers. It is exactly the kind of detail that makes a well-built mechanism worthless, and we resolve it in 04-05.
- Implementation:
src/middleware/rate-limit.js
src/middleware/rate-limit.js// src/middleware/rate-limit.js (NEW file)
import rateLimit from 'express-rate-limit';
import { errors } from '../errors/api-error.js';
import { environment } from '../config/environment.js';
/**
* Grouping key: third-party application > authenticated user > IP.
*/
function rateLimitKey(req) {
if (req.user?.oauthClient) return `oauth:${req.user.oauthClient}`;
if (req.user?.id) return `usr:${req.user.id}`;
return `ip:${req.ip}`;
}
/**
* The common handler: it sets our own headers and delegates to the error
* middleware from 03-07, so that the body has EXACTLY the catalogue's format.
*/
function onLimitExceeded(req, res, next, options) {
const resetMs = req.rateLimit.resetTime?.getTime() ?? Date.now() + options.windowMs;
const waitSeconds = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));
res.set('Retry-After', String(waitSeconds));
res.set('Aroma-RateLimit-Limit', String(req.rateLimit.limit));
res.set('Aroma-RateLimit-Remaining', '0');
res.set('Aroma-RateLimit-Reset', String(Math.ceil(resetMs / 1000)));
next(
errors.rateLimitExceeded(
'rate_limit_exceeded',
`You have exceeded the request limit. Try again in ${waitSeconds} seconds.`
)
);
}
/** Options shared by every limiter. */
const common = {
standardHeaders: false, // we do not emit the standard RateLimit-* yet (see 04-04 §9)
legacyHeaders: false, // NEVER X-RateLimit-*: the contract uses the Aroma- prefix
keyGenerator: rateLimitKey,
handler: onLimitExceeded,
// Disabled in tests: otherwise the 03-08 suite starts failing on its own.
skip: () => environment.NODE_ENV === 'test',
};
/** 1. The global limit. A safety net for all traffic. */
export const globalLimit = rateLimit({
...common,
windowMs: 60_000,
limit: (req) => {
if (!req.user) return 60; // anonymous
if (req.user.role === 'employee' || req.user.role === 'administrator') return 2000;
if (req.user.role === 'partner') return 1000;
return 300; // customer
},
});
/** 2. Login: very strict and only over FAILED attempts. */
export const loginLimit = rateLimit({
...common,
windowMs: 15 * 60_000,
limit: 5,
skipSuccessfulRequests: true, // a successful login does not use up the allowance
keyGenerator: (req) => {
// A double key: IP + the account under attack. It also stops the distributed attack.
const email = (req.body?.email ?? '').toLowerCase().trim();
return `login:${req.ip}:${email}`;
},
});
/** 3. Search: an expensive, uncached query. */
export const searchLimit = rateLimit({ ...common, windowMs: 60_000, limit: 20 });
/** 4. Writes: they protect the transactions. */
export const writeLimit = rateLimit({
...common,
windowMs: 60_000,
limit: (req) => (req.user?.role === 'partner' ? 300 : 60),
});And the error factory missing from src/errors/api-error.js (the rate_limit_exceeded code was already in the catalogue from 02-04; we are only adding its constructor):
// src/errors/api-error.js (MODIFIED)
export const errors = {
// ... notFound, conflict, notAuthenticated, permissionDenied, invalidData
rateLimitExceeded: (code, message) => new ApiError(429, code, message),
serviceUnavailable: (code, message) => new ApiError(503, code, message),
};Where it is registered in src/app.js
// src/app.js (extract after 04-04)
app.disable('x-powered-by'); // 1
app.use(assignTraceId); // 2
app.use(securityHeaders); // 3 helmet (04-02)
// (4) cors → 04-05
// (5) logRequests → 04-07
app.use(globalLimit); // 6 ← NEW
app.use(express.json({ limit: '100kb', /* ... */ })); // 7
app.get('/health', ...); // 8
app.use('/v1', v1Routes); // 9
app.use(notFoundHandler); // 10
app.use(errorHandler); // 11Why position 6, and neither earlier nor later:
- After helmet and CORS, so that the
429carries the security headers and, above all, the CORS ones: otherwise the SPA receives an opaque network error instead of a readable429. - Before the JSON parser. If the limiter came afterwards, your server would be parsing and validating 100 kB of JSON from requests it is going to reject anyway. Rejecting cheaply is half of an availability defence.
- Before the routes, obviously, because it protects all of them.
- After the logging (position 5, 04-07), so that the
429s are recorded: otherwise the attack is invisible in the logs at precisely the moment you most need to see it.
There is a deliberate exception to "before the parser": loginLimit needs req.body.email for its key, so it is registered inside the route, after the parser:
// src/routes/sessions.js (MODIFIED)
router.post(
'/',
loginLimit, // ← BEFORE authenticate: there is no user yet
validate(loginSchema, 'body'),
asyncHandler(controllers.sessions.create)
);
// src/routes/coffees.js (MODIFIED)
router.get(
'/',
authenticateOptional,
(req, res, next) => (req.query.q ? searchLimit(req, res, next) : next()),
validate(coffeeQuerySchema, 'query'),
asyncHandler(controllers.coffees.list)
);
// src/routes/orders.js (MODIFIED)
router.post(
'/',
authenticate,
requireRole('customer', 'employee', 'administrator'),
writeLimit, // ← AFTER authenticate: the key is the user
requireIdempotencyKey,
validate(createOrderSchema, 'body'),
asyncHandler(controllers.orders.create)
);The general rule that sums up the ordering: the anonymous limit goes before authenticating; the per-user limit goes after.
- In-memory store versus Redis
express-rate-limit's default store is a Map inside the process. With a single instance it works. With three, this happens:
graph TD C[Client: 60 requests/min] --> B[Load balancer] B -->|20 requests| A1[Instance 1: counts 20 of 60 - allows] B -->|20 requests| A2[Instance 2: counts 20 of 60 - allows] B -->|20 requests| A3[Instance 3: counts 20 of 60 - allows] A1 --> R[Real limit applied: 180/min with a limit of 60] A2 --> R A3 --> R
The effective limit is multiplied by the number of instances, and it is erratic on top of that: it depends on how the load balancer distributes traffic. Worse still, a deployment restarts the processes and wipes every counter, so an attacker only has to wait for your next deployment.
| Memory | Redis | |
|---|---|---|
| Instances | 1 | N |
| Precision with N instances | Limit × N | Exact |
| Survives restarts | No | Yes |
| Added latency | 0 | ~1 ms on the same network |
| Single point of failure | No | Yes: you have to decide what happens if Redis goes down |
| Complexity | None | One more service to operate |
// src/config/redis.js (NEW file)
import Redis from 'ioredis';
import { environment } from './environment.js';
export const redis = new Redis(environment.REDIS_URL, {
maxRetriesPerRequest: 2,
enableOfflineQueue: false, // if Redis is down, fail fast instead of queueing
});
redis.on('error', (e) => {
// Nothing is thrown: the API must keep serving even if Redis is down.
console.error('Redis unavailable:', e.message);
});// src/middleware/rate-limit.js (MODIFIED)
import RedisStore from 'rate-limit-redis';
import { redis } from '../config/redis.js';
const store = environment.REDIS_URL
? new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: 'aroma:rl:', // a prefix so it does not collide with the 04-06 cache
})
: undefined; // with no REDIS_URL, an in-memory store (development)
const common = {
// ... the rest unchanged
store,
};What to do if Redis goes down is an explicit design decision, not a detail:
- Fail-open (let requests through): the API keeps working without limits. It prioritises availability; it is most implementations' default and the one Aroma Store chooses for the general limit.
- Fail-closed (reject): safer, but it turns a Redis outage into a total outage.
A reasonable compromise: fail-open on the general limit, fail-closed on the login, where the brute-force risk outweighs availability. And in both cases, an alert: losing your rate limiting and not finding out is worse than either option.
- Other availability defences
Rate limiting does not reach everything. The complete picture:
| Defence | What it prevents | Status in the project |
|---|---|---|
Body limit (limit: '100kb') |
A 500 MB POST |
Added in 03-02 |
| Server timeouts | Connections held open for ever (Slowloris) | Below |
| Outbound timeouts | A slow third party blocking your processes | On every fetch |
expand limit |
Exponential queries | Depth 2 + allowlist (04-01) |
limit maximum of 100 |
Giant pages | 03-03 |
offset maximum |
An OFFSET that sweeps the table |
03-03 |
| Cost per query | Every request counting the same | Below |
| Circuit breaker | Hammering a service that is already down | Below |
| Backpressure | Accepting more load than you can process | A bounded queue |
Server timeouts. Node accepts connections that send nothing, and a Slowloris attack uses them to exhaust the pool:
// src/server.js (MODIFIED)
const server = app.listen(environment.port);
server.headersTimeout = 10_000; // 10 s to send the complete headers
server.requestTimeout = 30_000; // 30 s for the whole request
server.keepAliveTimeout = 65_000; // greater than the load balancer's (typically 60 s)keepAliveTimeout is a classic source of random 502s: if your server closes the reused connection before the load balancer does, the load balancer sends a request over a connection that is closing. The rule is that Node's must be greater than the proxy's.
Cost per query. Not all requests cost the same, and the token bucket from section 4 already accepts a cost:
| Operation | Cost in tokens |
|---|---|
GET /v1/coffees/{id} |
1 |
GET /v1/coffees?limit=100 |
3 |
GET /v1/coffees?q=... |
5 |
GET /v1/orders?expand=items.coffee |
5 |
POST /v1/orders |
10 |
It is fairer than counting requests and it is what mature APIs do (GitHub calls them "points"). It needs documenting well, because a limit in abstract units is harder to understand.
Circuit breaker. If the payment gateway is down, carrying on calling it with a 30-second timeout consumes your processes and delays the other side's recovery. The pattern has three states: closed (everything passes), open (after N failures, requests are rejected immediately without calling) and half-open (after a while, one probe request is let through). It is what turns "payments are down" into a fast 503 rather than a general outage.
Backpressure. When the incoming work exceeds what you can process, the correct response is to reject (503), not to queue indefinitely. An unbounded queue only swaps a fast outage for a slow one with all your memory consumed.
- 503,
Retry-After and service_unavailable
Retry-After and service_unavailable429 and 503 get confused and they are not the same:
| 429 | 503 | |
|---|---|---|
| Means | You have asked for too much | We cannot right now |
| Fault | The client's | The server's |
| Other clients | They are fine | They are affected too |
| Code | rate_limit_exceeded |
service_unavailable |
Retry-After |
Yes, calculated | Yes, estimated |
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: application/json
{
"error": {
"code": "service_unavailable",
"message": "The service is temporarily unavailable. Try again in 2 minutes.",
"details": [],
"traceId": "trc_9f3a2b7c"
}
}Note the traceId: this is a 5xx, so the contract from 03-07 requires it. On the 429, which is a 4xx, it does not appear.
When 503 is used in Aroma Store: scheduled maintenance, the database unavailable, an open circuit breaker towards the payment gateway, or overload detected by backpressure. And always with Retry-After, even if it is an estimate: without it, every client retries at the same moment and the effect is the thundering herd that stops the service recovering.
- What a well-behaved client does
On the other side of the contract, this is what the consumer should do:
/**
* An HTTP client with correct retries for the Aroma Store API.
*
* Rules:
* - Only retries what is safe to retry.
* - Respects Retry-After when the server states it.
* - Exponential backoff with jitter when it does not.
* - A cap on attempts: retrying for ever is an attack.
*/
const RETRIABLE = new Set([429, 502, 503, 504]);
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function requestWithRetries(url, options = {}, maxAttempts = 4) {
const method = (options.method ?? 'GET').toUpperCase();
// Only idempotent methods are retried, or a POST with an idempotency key.
const safeToRetry =
['GET', 'HEAD', 'PUT', 'DELETE'].includes(method) ||
Boolean(options.headers?.['Idempotency-Key']);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const response = await fetch(url, options);
if (!RETRIABLE.has(response.status) || !safeToRetry) return response;
if (attempt === maxAttempts) return response; // the failure is returned, not hidden
// 1. If the server says how long to wait, it is obeyed. Full stop.
const retryAfter = Number(response.headers.get('Retry-After'));
let waitMs;
if (Number.isFinite(retryAfter) && retryAfter > 0) {
waitMs = retryAfter * 1000;
} else {
// 2. Otherwise, exponential backoff: 1 s, 2 s, 4 s, 8 s...
const base = 1000 * 2 ** (attempt - 1);
// 3. Jitter: up to ±50% at random. ESSENTIAL.
waitMs = base * (0.5 + Math.random());
}
// 4. An absolute cap: never wait more than 60 s.
await wait(Math.min(waitMs, 60_000));
}
}Why jitter is not optional. If a thousand clients receive a 503 in the same second and they all wait exactly 1, 2, 4 and 8 seconds, all thousand come back at once, four times in a row. The service, which was recovering, falls over again with each wave. Randomness spreads the return out over time and it is the difference between recovering in a minute and not recovering at all. It is a distributed systems failure so common that it has its own name: the thundering herd.
And the three complementary rules:
- Slow down before you hit the wall: if
Aroma-RateLimit-Remainingdrops below a threshold, space the requests out rather than waiting for the429. - Do not retry client
4xxs. A400or a422is going to fail the same way the second time. Only429and5xx. - Cache. The best retry is the request that is never made: if the catalogue has not changed, do not ask for it again. That is exactly the subject of 04-06.
- How limits are communicated in the documentation
An undocumented limit is an intermittent failure from the consumer's point of view. The documentation must answer five questions, and they are best kept on a single page:
- What the limits are, per tier and per endpoint, in a table like the one in section 8.
- How to know how much is left: the
Aroma-RateLimit-*headers, with a real example. - What happens when they are exceeded: the complete
429, with its body and itsRetry-After. - What the client should do: the backoff pattern, with copyable code like the one in section 14.
- How to ask for more: who to write to, with what justification and on what timescale.
In openapi.yaml the 429 response is declared on the affected operations, with its headers:
components:
responses:
RateLimitExceeded:
description: The request limit has been exceeded.
headers:
Retry-After:
description: Seconds to wait before retrying.
schema: { type: integer, example: 37 }
Aroma-RateLimit-Limit:
description: Requests allowed in the current window.
schema: { type: integer, example: 60 }
Aroma-RateLimit-Remaining:
description: Requests left in the current window.
schema: { type: integer, example: 0 }
Aroma-RateLimit-Reset:
description: The Unix instant (seconds) at which the window resets.
schema: { type: integer, example: 1786000437 }
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error:
code: rate_limit_exceeded
message: You have exceeded the request limit. Try again in 37 seconds.
details: []
- Where rate limiting lives in production
The Express middleware works, but it has a structural limitation: to reject the request, you have already received it. Your server has accepted the TCP connection, negotiated TLS and run middleware. Under a genuine attack, that is already too much work.
That is why, in production, rate limiting usually lives in layers:
| Layer | What it stops | Advantage | Limitation |
|---|---|---|---|
| CDN / WAF (Cloudflare, CloudFront) | Raw volume, DDoS, bots | The traffic never even reaches your network | It knows nothing of your business logic |
| API gateway (Kong, Apigee, AWS API Gateway) | Limits per consumer and plan | Centralised, no code changes | One more component to operate |
| Load balancer / nginx | Connections and rate per IP | Cheap and very fast | It only knows about IPs |
| Application (what we have done) | Business rules: per role, per endpoint, cost | It knows the context: who is asking and for what | It consumes the process's resources |
The layers complement each other, they do not replace each other. The CDN stops the DDoS your process would never survive; only the application knows that a partner may make 1,000 per minute and a customer 300. Keeping the limit in the application also has a practical advantage: it is the defence that still exists if somebody deploys the API without the CDN in front, or if an attacker finds the origin IP and calls it directly. We will look at gateways and their portals in 05-06.
- Testing the limit with
node:test
node:test// tests/integration/rate-limit.test.js
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import express from 'express';
import rateLimit from 'express-rate-limit';
import { errorHandler } from '../../src/middleware/errors.js';
import { errors } from '../../src/errors/api-error.js';
/**
* Careful: the 'test' environment `skip` disables the real limiters so that
* the 03-08 suite does not break. That is why we assemble a minimal app here with a
* limiter of our own: we are testing the BEHAVIOUR, not the global configuration.
*/
function createAppWithLimit(limit = 3) {
const app = express();
app.use(
rateLimit({
windowMs: 60_000,
limit,
standardHeaders: false,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
handler: (req, res, next) => {
const reset = req.rateLimit.resetTime.getTime();
const wait = Math.max(1, Math.ceil((reset - Date.now()) / 1000));
res.set('Retry-After', String(wait));
res.set('Aroma-RateLimit-Limit', String(req.rateLimit.limit));
res.set('Aroma-RateLimit-Remaining', '0');
next(errors.rateLimitExceeded('rate_limit_exceeded', `Wait ${wait} seconds.`));
},
})
);
app.get('/v1/coffees', (req, res) => res.json({ data: [], total: 0 }));
app.use(errorHandler);
return app;
}
describe('rate limiting', () => {
it('allows up to the limit and rejects the next one with a 429', async () => {
const app = createAppWithLimit(3);
for (let i = 1; i <= 3; i++) {
const r = await request(app).get('/v1/coffees');
assert.equal(r.status, 200, `request ${i} should have got through`);
}
const r = await request(app).get('/v1/coffees');
assert.equal(r.status, 429);
assert.equal(r.body.error.code, 'rate_limit_exceeded');
assert.deepEqual(r.body.error.details, []); // the contract always requires details
assert.ok(!('traceId' in r.body.error), 'traceId only on 5xx');
});
it('the 429 includes Retry-After and the Aroma-RateLimit headers', async () => {
const app = createAppWithLimit(1);
await request(app).get('/v1/coffees');
const r = await request(app).get('/v1/coffees');
assert.equal(r.status, 429);
const wait = Number(r.headers['retry-after']);
assert.ok(Number.isInteger(wait) && wait > 0, 'Retry-After must be a positive integer');
assert.equal(r.headers['aroma-ratelimit-limit'], '1');
assert.equal(r.headers['aroma-ratelimit-remaining'], '0');
assert.ok(!r.headers['x-ratelimit-limit'], 'no X- headers should be emitted');
});
it('different keys do not share an allowance', async () => {
const app = createAppWithLimit(1);
await request(app).get('/v1/coffees').set('X-Forwarded-For', '203.0.113.1');
// Without trust proxy, Supertest always comes from 127.0.0.1: this test checks
// that the allowance is per key, so the limiter has to tell them apart.
const r = await request(app).get('/v1/coffees').set('X-Forwarded-For', '203.0.113.2');
assert.ok([200, 429].includes(r.status)); // depends on trust proxy: see the comment
});
});Three things this test teaches:
- The
skipin the test environment is necessary but dangerous. Necessary because, without it, the 03-08 suite would start failing randomly as soon as it made more than 60 requests. Dangerous because it means the real limiters are not tested: hence the minimal app with the samehandler. - The contract is tested, not the implementation: code, body format, headers present and headers absent (
X-RateLimit-*must not appear). - The third test is deliberately loose and its comment explains why: without
trust proxyconfigured, Supertest always looks like it comes from127.0.0.1. Testing the per-IP separation requires configuringtrust proxyin the test app; it is a reminder that testing rate limiting by IP forces you to replicate the network topology.
To test the time-based limit without actually waiting, use node:test's fake clock:
import { mock } from 'node:test';
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
mock.timers.tick(61_000); // advance a minute: the window has renewedCommon Mistakes and Tips
Putting the limiter after the JSON parser. You parse 100 kB of requests you are going to reject. Reject as early as possible.
Putting the limiter before CORS. The browser receives an opaque network error instead of a 429, and the SPA developer loses an afternoon.
Using the in-memory store with several instances. The limit is multiplied by the number of processes and is wiped on every deployment.
Trusting X-Forwarded-For without configuring trust proxy correctly. Either you limit everybody as one, or the attacker changes identity at will.
Counting successful logins. A legitimate user who logs in and out ends up locked out.
Locking accounts on failed attempts and nothing more. It becomes a way of denying service to a specific user. A progressive delay is better than a hard lockout.
Not sending Retry-After. The polite client does not know how long to wait and the impolite one waits for nothing.
Sending the allowance headers only on the 429. Their value lies in letting the client slow down before it hits the wall.
Forgetting Access-Control-Expose-Headers. The SPA cannot read any of our own headers and the entire mechanism is invisible to it (04-05).
Tip: deploy in observation mode first. Count without blocking for two weeks, look at the real 99th percentile and set the limit comfortably above it.
Tip: log the 429s with their key. Knowing that they all come from oauth:catabox changes the diagnosis completely (04-07).
Tip: exempt your own monitoring. There is nothing worse than your health check burning through the allowance and firing false alerts.
Exercises
Exercise 1: choosing an algorithm and a key
For each situation, choose the algorithm (fixed window, sliding window, token bucket) and the key, and justify it:
- Protecting
POST /v1/sessionsfrom brute force. - Letting the SPA load a screen that makes 8 requests in a row, without punishing it.
- Limiting CataBox globally, even though it acts on behalf of 500 different users.
- Limiting
GET /v1/coffees?q=because each search costs 200 ms of CPU.
Exercise 2: a client with backoff
Write a function downloadCatalogue(pages) that walks GET /v1/coffees?limit=100&offset=N for pages pages, respecting the rate limiting: it must slow down pre-emptively when Aroma-RateLimit-Remaining is low, respect Retry-After on a 429 and not retry more than three times per page.
Exercise 3: diagnosing an incident
After deploying the rate limiting, support receives these complaints on the same day. Diagnose each one and propose the fix:
- (a) "From my company's office, the website stops working in the mornings. From home it is fine."
- (b) "My mobile app gets a 429 when the home screen opens, but only the first time after it has been closed for a while." (note: the app makes 8 requests on start-up)
- (c) "Our integration script gets random 429s even though we make 50 requests per minute and the limit is 300."
- (d) "The SPA shows a 'network error' instead of the limit message."
Solutions
Solution 1
| Case | Algorithm | Key | Justification |
|---|---|---|---|
| 1. Login | Strict sliding window | ip + email (double) |
No burst may be allowed: 5 attempts are 5, not 10 at the boundary. The double key stops both the concentrated attacker (IP) and the botnet-distributed one (account) |
| 2. The SPA's screen | Token bucket | Authenticated user | It is exactly the case it exists for: capacity 20, rate 5/s allows the burst of 8 and keeps the sustained rate bounded |
| 3. CataBox | Sliding window counter | client_id, aggregated |
Limiting per user would be useless: those are 500 different keys. The key must be just oauth:catabox, ignoring the sub. Ideally, both limits at once: per user and aggregated per client |
| 4. Search | Token bucket with a cost | User or IP | A cost of 5 tokens per search against 1 for a normal read: it reflects the real cost and avoids needing a separate counter |
Solution 2
const SLOWDOWN_THRESHOLD = 10; // below this, we space requests out
function wait(ms) {
return new Promise((r) => setTimeout(r, ms));
}
export async function downloadCatalogue(pages, token) {
const coffees = [];
for (let page = 0; page < pages; page++) {
const url = `https://api.aromastore.example/v1/coffees?limit=100&offset=${page * 100}`;
let response;
for (let attempt = 1; attempt <= 3; attempt++) {
response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (response.status !== 429) break;
if (attempt === 3) throw new Error(`persistent 429 on page ${page}`);
// The server says how long to wait: we obey, with jitter so as not to
// synchronise with other clients that received the same 429.
const retryAfter = Number(response.headers.get('Retry-After')) || 2 ** attempt;
await wait(retryAfter * 1000 * (1 + Math.random() * 0.2));
}
if (!response.ok) throw new Error(`Error ${response.status} on page ${page}`);
const body = await response.json();
coffees.push(...body.data);
if (coffees.length >= body.total) break; // do not ask for empty pages
// PRE-EMPTIVE slowdown: better to go slowly than to hit the 429.
const remaining = Number(response.headers.get('Aroma-RateLimit-Remaining'));
const reset = Number(response.headers.get('Aroma-RateLimit-Reset'));
if (Number.isFinite(remaining) && remaining < SLOWDOWN_THRESHOLD) {
const secondsToReset = Math.max(1, reset - Math.floor(Date.now() / 1000));
// What is left of the window is shared out among the requests we can still make.
await wait((secondsToReset / Math.max(1, remaining)) * 1000);
}
}
return coffees;
}The essentials: the pre-emptive slowdown means the 429 almost never happens, Retry-After is respected when it does, the jitter avoids synchronising with other clients, there is a cap on attempts, and the loop exits once all the items have been fetched according to total.
Solution 3
(a) NAT. The whole office comes out through one public IP. With 60/min for anonymous traffic, twenty employees browsing exhaust it. Fixes: raise the anonymous limit; authenticate as early as possible so as to move to the per-user limit (300/min); and, if the SPA loads the catalogue with no session, take advantage of HTTP caching (04-06) so that most of those requests never arrive at all.
(b) A boundary effect with a fixed window. The app makes 8 requests at once. If the limit was implemented with a strict fixed window, a burst after a period of inactivity can land right on the boundary. Fix: a token bucket with capacity ≥ 20 and rate 5/s, which is precisely the use case it exists for. If the problem were one of scale rather than burstiness, the alternative solution is reducing the 8 calls to 1 with expand, as we saw in 04-01.
(c) The in-memory store with several instances... the other way round. With memory and N instances the limit multiplies, so 50/min would never produce a 429. The fact that it does points to another cause: the key is badly chosen and groups several consumers together. Most likely the script does not send Authorization, falls into the ip: branch and shares its IP with other processes belonging to the same customer. Fix: authenticate the script (Client Credentials, 04-03) so that its key is its client_id. Another possibility: a badly configured trust proxy makes every client share the load balancer's IP.
(d) Access-Control-Expose-Headers is missing, or the limiter is before CORS. If the 429 is emitted without the Access-Control-Allow-Origin headers, the browser blocks the response and JavaScript only sees a generic network failure. Fixes: register cors before globalLimit in src/app.js (position 4 against position 6) and expose Retry-After and Aroma-RateLimit-* in Access-Control-Expose-Headers (04-05).
Conclusion
Availability is the only property of an API that anyone can destroy without exploiting a single vulnerability: calling it a lot is enough. You have seen why every public API needs limits — abuse, scraping, clients with loops, cost, fairness and protecting the database — the difference between rate limiting, throttling and quotas, and the four algorithms with their real trade-off between precision, memory and burst tolerance, with a token bucket implemented and annotated that includes the lazy refill and the clean-up that almost nobody writes. You know what you count against and why the IP is a problematic key, between NAT, IPv6 and proxies, with the exact trust proxy configuration; you have Aroma Store's limits per endpoint and per tier, and the complete 429 response with Retry-After and the Aroma-RateLimit-* headers. The project now has src/middleware/rate-limit.js with express-rate-limit, registered at position 6 of src/app.js — after helmet and CORS, before the JSON parser — with a Redis store for multiple instances and an explicit decision about what to do if Redis goes down. And you have added timeouts, cost per query, a circuit breaker and the 503 with service_unavailable, along with the client with exponential backoff and jitter that avoids the thundering herd.
There is a detail that has come up three times and can no longer be postponed: the SPA cannot read any of the headers we have just designed. In 04-05, CORS and Security Policies, we will start with the why: the browser's same-origin policy, what exactly an origin is and why curl and Aroma Mobile are unaffected. We will look at simple requests versus preflight with the complete raw OPTIONS exchange, all the protocol's headers — including Access-Control-Expose-Headers, which is the one that solves this problem — Aroma Store's real configuration with an allowlist per environment, why * and Allow-Credentials are incompatible, the table of console errors with their cause and their solution, the classic preflight that returns a 401 because authentication ran before CORS, and why we use Authorization: Bearer rather than cookies — which is what makes us immune to CSRF.
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
