A mock API that returns errors on demand
Get a 500, a 429 with Retry-After, a timeout or a truncated body whenever your tests need one.
Examples last run against the API on
To test how your code handles a failing API, point it at an endpoint that fails on purpose. https://api.testingapis.com is free and allows requests from any origin. Status codes need no key; the failure injection endpoints need a free account's API key in the X-TestingApis-Key header. /api/status/500 returns a 500 and /api/status/429 a 429 with Retry-After. Any code from 200 to 599 works. Adding ?error=503 to a real endpoint makes it fail, and /api/chaos/retry/{key}sign in to see fails a set number of times before it succeeds. /api/chaos/timeoutsign in to see hangs until your client gives up, and /api/chaos/partialsign in to seesends half a response. Here's each one, with the response it returns.
Which endpoint for which failure
| You want to test | Request | What comes back |
|---|---|---|
| A specific status code | GET /api/status/503 | That status, a JSON body, and the headers it normally carries |
| A failing real endpoint | GET /api/Products/1?error=500 | The status you asked for, with X-Simulated: true |
| A slow real endpoint | GET /api/Products/1?delay=3 | The normal response after N seconds (maximum 10) |
| Retry logic | GET /api/chaos/retry/my-key?succeedAfter=2sign in to see | Two failures, then 200. X-Attempt counts the attempts |
| Random failures | GET /api/chaos/flaky?failRate=0.3sign in to see | 503 with Retry-After: 1 about 30% of the time |
| A client timeout | GET /api/chaos/timeout?seconds=30sign in to see | Nothing until the time is up (1 to 60 seconds) |
| A cut-off body | GET /api/chaos/partialsign in to see | Headers promise 10,000 bytes, about 64 arrive, then the connection closes |
| Real rate limiting | GET /api/RateLimitsign in to see | 429 after 5 requests in 60 seconds, with Retry-After |
| A weighted mix | GET /api/status/random?codes=200:3,500:1 | 200 three times as often as 500 |
Return any HTTP status code
/api/status/{code} accepts any method and responds with the code in the path. It also sends the headers a client would expect with that status: Retry-After on 429 and 503, WWW-Authenticate on 401, Location on redirects and Allowon 405. So you can check that your code reads them and doesn't just look at the number.
$ curl -i https://api.testingapis.com/api/status/429
HTTP/1.1 429 Too Many Requests
content-type: application/json; charset=utf-8
retry-after: 5
{"status":429,"statusText":"Too Many Requests","category":"client-error","message":"This is a 429 response. Use /api/status/{code} to request any status."}/api/status/429Make a real endpoint fail
A test that only ever calls /api/status/500doesn't prove much about the screen that loads products. Add error to the request your app already makes, and that call fails instead of returning data. ?delay=N waits N seconds first, so ?delay=3&error=503 lets you check that a loading state appears and then gives way to an error message.
$ curl -i "https://api.testingapis.com/api/Products/1?error=503"
HTTP/1.1 503 Service Unavailable
x-simulated: true
{"status":503,"error":"Service Unavailable","message":"Simulated error. Use ?error={code} to test different status codes.","simulated":true}/api/Products/1?error=503Both parameters work on every endpoint, including writes. The full list is in the API documentation.
Test retries without guessing
Random failures make retry tests flaky. /api/chaos/retry/{key}sign in to see is deterministic: with succeedAfter=2, the first two requests for a key fail and the third succeeds. failStatus picks the failure status (429, 500, 502, 503 or 504), and every response has an X-Attempt header, so a test can assert how many attempts your client made. Use a fresh key per test run, or reset it with DELETE.
$ curl -i "https://api.testingapis.com/api/chaos/retry/my-test?succeedAfter=2&failStatus=429"
HTTP/1.1 429 Too Many Requests
retry-after: 1
x-attempt: 1
$ curl -i "https://api.testingapis.com/api/chaos/retry/my-test?succeedAfter=2&failStatus=429"
HTTP/1.1 429 Too Many Requests
retry-after: 1
x-attempt: 2
$ curl -i "https://api.testingapis.com/api/chaos/retry/my-test?succeedAfter=2&failStatus=429"
HTTP/1.1 200 OK
x-attempt: 3
$ curl -X DELETE https://api.testingapis.com/api/chaos/retry/my-test
{"message":"Counter for 'my-test' reset after 3 attempt(s).","key":"my-test"}Here's a test for a retrying client, using Node's built-in test runner. A complete fetchWithRetry with exponential backoff and Retry-After support is in the timeouts, retries and rate limits lesson.
import { test } from "node:test";
import assert from "node:assert/strict";
import { fetchWithRetry } from "./client.js"; // your code under test
test("retries a 503 and returns the eventual 200", async () => {
const key = `ci-${Date.now()}`;
const res = await fetchWithRetry(
`https://api.testingapis.com/api/chaos/retry/${key}?succeedAfter=2`,
);
assert.equal(res.status, 200);
assert.equal(res.headers.get("x-attempt"), "3");
});
test("gives up on a 400 without retrying", async () => {
const res = await fetchWithRetry("https://api.testingapis.com/api/status/400");
assert.equal(res.status, 400);
});Timeouts and broken connections
Some failures never produce a status code. Your client has to notice them itself, and a script that only checks res.ok never sees them. This script calls five failing endpoints and prints what fetch does with each:
const BASE = "https://api.testingapis.com";
async function attempt(label, url, init) {
try {
const res = await fetch(url, init);
const body = await res.text();
console.log(label.padEnd(8), res.status, res.headers.get("retry-after") ?? "-", body.slice(0, 50));
} catch (err) {
console.log(label.padEnd(8), "threw", err.name, err.cause?.code ?? err.message);
}
}
await attempt("500", `${BASE}/api/status/500`);
await attempt("429", `${BASE}/api/status/429`);
await attempt("error=", `${BASE}/api/Products/1?error=503`);
await attempt("timeout", `${BASE}/api/chaos/timeout?seconds=10`, { signal: AbortSignal.timeout(2000) });
await attempt("partial", `${BASE}/api/chaos/partial`);500 500 - {"status":500,"statusText":"Internal Server Error
429 429 5 {"status":429,"statusText":"Too Many Requests","c
error= 503 - {"status":503,"error":"Service Unavailable","mes
timeout threw TimeoutError The operation was aborted due to timeout
partial threw TypeError UND_ERR_SOCKETThe timeout and the cut-off body both throw instead of returning a response. The timeout throws because AbortSignal.timeout(2000) gave up after two seconds. Without a signal, fetch would have waited the full ten. The partial response throws while reading the body, after the status and headers arrived looking healthy. curl reports the same problem:
$ curl -sS -o /dev/null https://api.testingapis.com/api/chaos/partial
curl: (18) end of response with 9936 bytes missingA real rate limiter
/api/status/429 fakes a single response. /api/RateLimitsign in to see is a real fixed-window limiter: five requests per 60 seconds per client, with X-RateLimit-Remaining counting down and a 429 with an accurate Retry-After on the sixth. Send an X-Client-Id header so your tests get their own window, and POST /api/RateLimit/resetsign in to see clears it. The rate limit reference has every header.
/api/RateLimitWhat to check in your client
- 4xx errors other than 408 and 429 are not retried: a bad request stays bad.
- 429 and 503 wait for
Retry-Afterinstead of retrying at once. - Retries stop after a maximum number of attempts, and the last error reaches the user.
- Every request has a timeout, and a timeout shows an error instead of an endless spinner.
- A body that fails to parse or arrives incomplete is an error, even with a 200 status.
- An error body's
messageis shown or logged, not swallowed.