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
- Node 18 or later. The limiter below is the target for every command on this page. See the Node http documentation.
- curl 7.0 or later. See the curl manual.
- Save this file as
limiter.js. It is a fixed window of five requests per 30 seconds, keyed on the client address, and it answers over the limit with 429 plusretry-after.
// 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');
- Save this one as
burst.sh. Step 5 uses it to cross the window boundary on purpose.
# 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 ))"
- Run
node limiter.jsin a second terminal and leave it there. Stop it with Ctrl+C when the run is over.
Steps
- 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 GMTThe 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.
- 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/; done1 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=30Four succeed because step 1 already took the fifth. The flip lands exactly where the counter says it will, and
retry-afterappears only on the refusals. - 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.
- 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=4This 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.
- Step 5.
Cross the window boundary on purpose and count what gets through.
bash burst.sh200 200 200 429 200 200 200 200 200 8 accepted calls in 5 s, the limit is 5 per 30 sThe 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
What to check next
- How to check rate limit headers: read the quota policy without spending it.
- How to test retry-after header: the wait instruction this test relies on, in both of its formats.
- How to test API concurrency: parallel calls can slip past a counter that is incremented after the handler runs.
- How to test API idempotency: a client that retries after 429 must not create the resource twice.
- How to check HTTP status code: confirm the refusal is 429 and not a generic error.
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.
Related on this site
intermediate6 minpublished updated Maks Verny