← Recipes

阅读中文版本

Verify Rate Limiting Holds Under a Burst of Concurrent Requests

Prove that an express-rate-limit middleware actually throttles by firing concurrent bursts, checking 429 headers, and counting admitted versus rejected requests.

~30 minapirate-limitingload-testing

Task

Fire concurrent bursts at a rate-limited endpoint and prove the limit, the 429 status, and the Retry-After behavior all hold.

When to use this

A rate limit was configured months ago and nobody has retested it. A marketing campaign doubles traffic and the endpoint still accepts everything because the store was per-process.

Prerequisites

  • Node.js 20+ with an Express app using express-rate-limit
  • hey or a similar concurrent load generator installed
  • A disposable staging endpoint safe to flood

Steps

1. Confirm the limiter configuration and its window semantics.

grep -rn "rateLimit(" src/ | head -5

Expected output: The limiter shows a windowMs and a max, for example 60_000 and 100, with a documented store.

2. Fire a burst well above the limit from a single client and inspect the response codes.

hey -n 300 -c 30 -m GET https://staging.example.com/api/search -o /dev/null 2>&1 | grep -E "Status code|Responses"

Expected output: Roughly 100 requests return 200 and the rest return 429; hey prints the status code distribution.

3. Inspect the headers the limiter returns once throttled.

curl -s -D - -o /dev/null https://staging.example.com/api/search | grep -iE "ratelimit|retry"

Expected output: Headers such as RateLimit-Limit, RateLimit-Remaining: 0, and RateLimit-Reset appear, letting clients back off.

4. Prove the counter resets after the window by waiting and replaying.

sleep 61 && hey -n 10 -c 10 -m GET https://staging.example.com/api/search -o /dev/null 2>&1 | grep "Status code"

Expected output: All 10 requests return 200, confirming the window resets rather than accumulating forever.

5. If the app runs multiple processes, verify the shared store by comparing two processes.

const limiter = rateLimit({
  windowMs: 60_000,
  limit: 100,
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
});

Expected output: With a Redis or memcached store the same burst test now rejects above 100 in total instead of 100 per process.

Troubleshooting

Problem

All requests pass even far above the limit

Solution

The counter lives in process memory while the app runs several workers; move the store to Redis so all workers share one counter.

Problem

Limit works locally but not behind the proxy

Solution

The app trusts a wrong client IP because the proxy header is unhandled; set trust proxy correctly or key the limiter on the authenticated subject.

Problem

Burst test itself returns connection errors

Solution

The load generator opened more sockets than the sandbox allows; lower the concurrency flag or raise the local ephemeral port and file descriptor limits.

Limitations

  • Load generators measure the whole path, so a slow backend can mask throttling results; assert on status codes, not latency.
  • Keying limits by IP punishes users behind carrier-grade NAT; combine IP with an API key or account tier in production.

A limit nobody has tested is a rumor

Rate limiting is easy to configure and easy to get silently wrong: the wrong key, an in-memory store behind a load balancer, or a proxy that strips the headers clients need to back off. A five-minute burst test converts the rumor into evidence. Fire more requests than the limit allows from several connections, then read the outcome from status codes and headers instead of from the config file.

hey -n 300 -c 30 -m GET https://staging.example.com/api/search -o /dev/null

# Status code distribution:
#   [200] 100 responses
#   [429] 200 responses

Count admitted, not sent

The number that matters is how many requests the server admitted in the window, not how many the client sent. Status code distribution gives you exactly that: a clean 100/200 split means the limiter enforced max=100 in a 60-second window. If you see 200/300 with four workers, every worker kept its own counter, and the effective limit was 400.

Give clients a way to behave

Rejecting with 429 is only half the contract. The response should carry RateLimit-Remaining and RateLimit-Reset, or Retry-After, so well-behaved clients can schedule retries instead of hammering in a tight loop. Assert these headers exist in the throttled response, not only the status code.