How to test API rate limiting

Start a limiter you control, then send more calls than its window allows and read the status of each one. With a ceiling of five per 30 seconds, five requests come back 200 and the rest 429 carrying retry-after. The part worth testing is the recovery after the advertised wait, not the refusal.

Why check this

A limiter is two behaviours, and only one of them is visible in a normal test run. Refusing traffic is the easy half. Letting it back in on schedule is the half that breaks, usually after a deploy that changes the window or moves the counter from one process to a shared store. Run this on staging sign-off and after any change to the gateway. Without it a partner integration gets stuck at 429 for an entire day because the counter is written once and never expires.

Prerequisites

// limiter.js  fixed window: 5 requests per 30 seconds, counted per client address
const http = require('node:http');
const LIMIT = 5;
const WINDOW = 30;
const hits = new Map();

http.createServer((req, res) => {
  const now = Math.floor(Date.now() / 1000);
  const key = req.socket.remoteAddress;
  let w = hits.get(key);
  if (!w || now >= w.reset) {
    w = { count: 0, reset: now + WINDOW };
    hits.set(key, w);
  }
  w.count += 1;
  res.setHeader('x-ratelimit-limit', String(LIMIT));
  res.setHeader('x-ratelimit-remaining', String(Math.max(0, LIMIT - w.count)));
  res.setHeader('x-ratelimit-reset', String(w.reset));
  if (w.count > LIMIT) {
    res.setHeader('retry-after', String(w.reset - now));
    res.writeHead(429, { 'content-type': 'application/json' });
    res.end('{"error":"too many requests"}');
    return;
  }
  res.writeHead(200, { 'content-type': 'application/json' });
  res.end('{"ok":true}');
}).listen(8787, '127.0.0.1');
# burst.sh  drives one fixed window across its boundary and counts the accepted calls
reset=$(curl -s -o /dev/null -D - http://127.0.0.1:8787/ | tr -d '\r' | sed -n 's/^x-ratelimit-reset: //p')
sleep $(( reset - $(date +%s) - 4 ))
start=$(date +%s)
ok=0
for i in $(seq 1 9); do
  [ "$i" = 5 ] && sleep 5
  code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8787/)
  printf '%s ' "$code"
  [ "$code" = 200 ] && ok=$((ok + 1))
done
printf '\n%s accepted calls in %s s, the limit is 5 per 30 s\n' "$ok" "$(( $(date +%s) - start ))"

Steps

  1. Step 1.

    Send one request and record the policy the server advertises.

    curl -s -D - -o /dev/null http://127.0.0.1:8787/
    
    HTTP/1.1 200 OK
    x-ratelimit-limit: 5
    x-ratelimit-remaining: 4
    x-ratelimit-reset: 1789153890
    content-type: application/json
    Date: Fri, 11 Sep 2026 19:11:00 GMT

    The window is now open and one of its five calls is spent. Note the reset value, because every later number on this page is measured against it.

  2. Step 2.

    Send seven more calls in one loop and print the status of each.

    for i in $(seq 1 7); do curl -s -o /dev/null -w "$i %{http_code} remaining=%header{x-ratelimit-remaining} retry-after=%header{retry-after}\n" http://127.0.0.1:8787/; done
    
    1 200 remaining=3 retry-after=
    2 200 remaining=2 retry-after=
    3 200 remaining=1 retry-after=
    4 200 remaining=0 retry-after=
    5 429 remaining=0 retry-after=30
    6 429 remaining=0 retry-after=30
    7 429 remaining=0 retry-after=30

    Four succeed because step 1 already took the fifth. The flip lands exactly where the counter says it will, and retry-after appears only on the refusals.

  3. Step 3.

    Read one refusal in full, headers and body together.

    curl -s -i http://127.0.0.1:8787/
    
    HTTP/1.1 429 Too Many Requests
    x-ratelimit-limit: 5
    x-ratelimit-remaining: 0
    x-ratelimit-reset: 1789153890
    retry-after: 30
    content-type: application/json
    
    {"error":"too many requests"}

    A refusal a client can act on has three parts: the 429 status, a wait it can obey, and a body that names the reason. Check that all three are present before the limiter is called done.

  4. Step 4.

    Wait exactly as long as the server asked, then send one more call.

    sleep "$(curl -s -o /dev/null -w '%header{retry-after}' http://127.0.0.1:8787/)"; curl -s -o /dev/null -w 'after the wait: %{http_code} remaining=%header{x-ratelimit-remaining}\n' http://127.0.0.1:8787/
    
    after the wait: 200 remaining=4

    This is the assertion that matters. The quota came back at the advertised moment, and the fresh window reports four calls left, so the counter reset rather than drifting.

  5. Step 5.

    Cross the window boundary on purpose and count what gets through.

    bash burst.sh
    
    200 200 200 429 200 200 200 200 200
    8 accepted calls in 5 s, the limit is 5 per 30 s

    The single 429 is the tail of the old window. Then the counter resets and five more calls go through at once, so eight requests were served in five seconds under a ceiling of five per thirty.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 200 up to the ceiling, then 429 | The limiter counts and enforces | Move on to the recovery test in step 4. | | 429 on the very first call | The previous window is still open | Wait out x-ratelimit-reset or restart the server before you assert anything. | | 200 forever | Nothing is counting on this route | The limiter sits on another path, or on a proxy that this URL bypasses. | | 429 without retry-after | The client is refused and told nothing | The only correct client behaviour left is a blind backoff. File it as a defect. | | Still 429 after the advertised wait | The counter does not expire | Look for a fixed counter written once, or a reset value computed from a different clock. | | 403 or 500 instead of 429 | The refusal is mislabelled | Retry logic keyed on 429 will not fire. Fix the status before testing the timing. |

Common mistakes

Sign: Rerunning the test gives 429 from the first request.Cause: A fixed window keeps its counter for the whole window. A rerun eight seconds after the previous one inherits a spent quota. Restart the limiter or wait past x-ratelimit-reset between runs, otherwise the test is timing dependent.
Sign: The suite proves 429 is returned and stops there.Cause: Refusing traffic is the half that rarely breaks. The counter expiring on schedule is the half that does, usually after a move from an in-process map to a shared store where the key never gets a time to live. Only a wait-then-retry step exercises it.
Sign: Twice the limit passes in a few seconds and nothing is refused.Cause: A fixed window releases its whole quota at one instant, so calls at the end of one window and the start of the next arrive back to back. The run in step 5 served eight calls in five seconds against a ceiling of five per thirty. A sliding window or a token bucket does not behave this way.
Sign: Two testers see different limits on the same endpoint.Cause: The counter key differs from what the documentation implies. Per address, per credential and per route are three separate policies, and an address key counts everyone behind one office NAT as a single client.

What to check next

FAQ

How to test rate limiting in Postman?

Point a request at the same local limiter, open the Collection Runner, and set iterations above the ceiling. Add one test that asserts the status is 429 once the quota is gone, and a second that asserts 200 after a delay equal to retry-after.

How to test 429 too many requests?

Drive a limiter you control until it refuses, as in step 2, then read the whole response as in step 3. Testing 429 against someone else's API means sending a burst at a service you do not own, which is a load test without permission.

Can I test rate limiting against a third-party API?

Not by bursting it. Read the quota headers on single requests instead, and stub the 429 locally for the client-side path. The behaviour under test is your client's reaction, and a local limiter reproduces it exactly.

Should rate limit tests run in CI?

Yes, against a local limiter started by the suite. A 30 second window makes one recovery assertion cost 30 seconds, so use a shorter window in CI and keep the long one for the manual run.

Why does the count reset all at once?

Because a fixed window stores one counter and one expiry. Sliding windows and token buckets spread the refill over time, which removes the boundary burst in step 5 at the cost of more state per client.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate6 minpublished updated Maks Verny