Testing a client's resilience
Fault injection, fake clocks, time limits in assertions, and a test suite for the whole client.
Error handling has an awkward property: it runs rarely. On a normal day the server answers, the retry loop never loops and the circuit breaker never opens. So a bug in that code can sit unnoticed for months and show up during an outage, the worst possible moment. The only way to know it works is to make the failures happen on purpose, in tests. This is called fault injection, and this lesson puts the whole client from the tutorial under it.
Two kinds of resilience test
The tests in the example project come in two kinds, and a good suite uses both:
| Fakes | Real injected failures | |
|---|---|---|
| What it is | Made-up responses, a fake clock, a fixed random number | Requests to endpoints that fail on purpose |
| Speed | Milliseconds | Seconds: real timeouts and real waits |
| Repeatable | Exactly the same every run | Nearly, if the failures are deterministic |
| Tests | Your logic: the decisions, the state changes, the arithmetic | The real behavior of fetch, the network and any proxies |
| Misses | Anything your fake doesn't imitate correctly | Cases that are hard to cause on demand |
| Examples here | The retry decision, backoff, the breaker with a fake clock | Timeouts, cut-off bodies, the rate limiter, payments |
Fakes check that the code does what you meant. Real failures check that what you meant matches reality. Lesson 1 is the argument for the second kind: no fake would have predicted that a dropped connection comes back as a plain-text 503 from a proxy that retried it.
The failures you can inject
Every failure in this tutorial came from one of these:
| Endpoint or option | Causes | Lesson |
|---|---|---|
/api/chaos/flaky?failRate=0.5sign in to see | 503 at random, with Retry-After: 1 | 1, 7 |
/api/chaos/retry/{key}?succeedAfter=3sign in to see | Fails a set number of times per key, then succeeds | 3 |
/api/chaos/timeout?seconds=30sign in to see | A response that takes up to 60 seconds | 2 |
/api/chaos/slow?minMs=1000&maxMs=3000sign in to see | A random wait within a range | 2 |
/api/chaos/random-latencysign in to see | Mostly fast, sometimes slow, rarely very slow | 2 |
/api/chaos/abortsign in to see | A dropped connection (a 503 through the proxy) | 1 |
/api/chaos/partialsign in to see | A body that is cut off partway | 1 |
/api/chaos/huge-headerssign in to see | Up to 100 large headers, which some clients refuse | None; try it |
/api/stream/dripsign in to see | A body that arrives slowly | 2 |
?delay=2 and ?error=503 | Added to any URL: wait up to 10 seconds, or answer with that status (400, 401, 403, 404, 408, 429, 500, 502 or 503) | 1 |
/api/RateLimitsign in to see | A real limit of 5 requests a minute per client | 5 |
/api/paymentssign in to see | Idempotency keys, with processingSeconds to make it slow | 2, 6 |
The failure injection API reference lists every parameter. Prefer the deterministic ones in tests: retry/{key} fails exactly when you say, while flaky with a rate between 0 and 1 is random and belongs in demos and soak tests, not in a suite that must pass every time.
The whole client
src/client.mjs combines everything from lessons 2 to 7. It returns a request function that is used much like fetch:
// Everything from the tutorial in one client: a deadline per attempt, retries with backoff that respect
// Retry-After, idempotency keys for POST, and a circuit breaker around every attempt.
import { BASE_URL, apiHeaders } from "./api.mjs";
import { CircuitBreaker } from "./circuit-breaker.mjs";
import { withRetries } from "./retry.mjs";
import { fetchWithTimeout } from "./timeout.mjs";
export function createClient({
baseUrl = BASE_URL,
timeoutMs = 5000,
maxAttempts = 3,
breaker = new CircuitBreaker(),
onRetry,
} = {}) {
return async function request(path, { method = "GET", body, idempotencyKey, headers = {} } = {}) {
const extra = {};
if (body !== undefined) extra["Content-Type"] = "application/json";
if (idempotencyKey) extra["Idempotency-Key"] = idempotencyKey;
const attempt = () =>
breaker.call(async () => {
const response = await fetchWithTimeout(`${baseUrl}${path}`, {
method,
timeoutMs,
headers: apiHeaders({ ...extra, ...headers }),
body: body === undefined ? undefined : JSON.stringify(body),
});
// Read the body inside the attempt, so a body that is cut off counts as a failed attempt too.
const text = await response.text();
const isJson = response.headers.get("content-type")?.includes("json");
return { status: response.status, headers: response.headers, body: isJson && text ? JSON.parse(text) : text };
});
return withRetries(attempt, { method, idempotencyKey, maxAttempts, onRetry });
};
}The order of the layers is deliberate. From the inside out:
- One attempt is
fetchWithTimeoutplus reading the body. Reading inside the attempt means a body that gets cut off is a failed attempt, which the retry loop can repeat, not an error that escapes later. - The circuit breaker wraps each attempt, so it counts every failure, and once open it stops attempts before they reach the network.
- The retry loop is outermost. It uses the method and idempotency key to decide what is safe to repeat, follows
Retry-After, and stops as soon as the breaker refuses, becauseCircuitOpenErrorisn't a temporary failure.
Testing the client
Each test in test/client.test.mjs injects one failure and checks one behavior. First, the everyday case: two failures, then success.
test("recovers from two temporary failures", async () => {
const request = createClient({ maxAttempts: 3 });
const response = await request(`/api/chaos/retry/${key}?succeedAfter=2`);
assert.equal(response.status, 200);
assert.equal(response.body.attempt, 3);
});response.body.attempt is 3 because the client parsed the JSON and the server counted three attempts. Next, a server that hangs. The test checks that the client gives up, and how fast:
test("gives up on a server that hangs, within a known time", async () => {
const request = createClient({ timeoutMs: 1000, maxAttempts: 2 });
const started = Date.now();
await assert.rejects(request("/api/chaos/timeout?seconds=10"), { name: "TimeoutError" });
// Two attempts of 1 s each, plus at most 200 ms of backoff, plus some slack for the network.
assert.ok(Date.now() - started < 4000);
});Asserting on time
Two attempts of 1 second each and up to 200 ms of backoff add up to 2.2 seconds, and the test allows 4. Why not assert 2.2 exactly? Because real requests take a variable time, and so does the machine running the tests. A test that demands exact timings fails now and then for no real reason, and a test that fails at random soon gets ignored. Assert a bound instead, with room for the network: well above what a working client takes, well below what a broken one would. A client with no timeout would take 10 seconds here, so the test still catches the bug it exists for.
The body that gets cut off, which should count as a failed attempt and be retried once:
test("treats a body that is cut off as a failed attempt", async () => {
const retries = [];
const request = createClient({ maxAttempts: 2, onRetry: ({ outcome }) => retries.push(outcome.message) });
await assert.rejects(request("/api/chaos/partial"), { message: "terminated" });
assert.deepEqual(retries, ["terminated"]);
});The breaker, which should stop the retries and then refuse new requests without touching the network:
test("stops calling a service that keeps failing", async () => {
const breaker = new CircuitBreaker({ failureThreshold: 2, openMs: 30_000 });
const request = createClient({ maxAttempts: 3, breaker });
// Attempts 1 and 2 fail and open the circuit; attempt 3 is refused without being sent.
await assert.rejects(request("/api/chaos/flaky?failRate=1"), CircuitOpenError);
assert.equal(breaker.state, "open");
const started = Date.now();
await assert.rejects(request("/api/products/1"), CircuitOpenError);
assert.ok(Date.now() - started < 50, "failed without touching the network");
});The last assertion uses time to prove a negative: a request that finished in under 50 ms can't have gone to the server and back. And the test that a permanent failure is left alone:
test("does not retry a request the server will always refuse", async () => {
const retries = [];
const request = createClient({ onRetry: (r) => retries.push(r) });
const response = await request("/api/products/9999");
assert.equal(response.status, 404);
assert.equal(retries.length, 0);
});The full run:
$ npm test
✔ recovers from two temporary failures (2254.2108ms)
✔ gives up on a server that hangs, within a known time (2219.1968ms)
✔ treats a body that is cut off as a failed attempt (2512.919ms)
✔ stops calling a service that keeps failing (3243.0976ms)
✔ does not retry a request the server will always refuse (83.4976ms)
...
ℹ tests 33
ℹ pass 33
ℹ fail 0
ℹ duration_ms 30901About 30 seconds, nearly all of it spent waiting on timeouts and Retry-After. That's normal for this kind of suite, and a reason to keep it separate from fast unit tests, as the next section explains.
Rules for resilience tests that stay reliable
- Make failures deterministic. Use counters like
retry/{key}, fake clocks and fixed random numbers. A test that depends on luck will eventually fail on a day when nothing is wrong. - Isolate shared state. Every test that uses a counter or a rate limit gets its own key or client ID, and resets it in an
afterhook. Two people running the suite at once should never affect each other. - Assert bounds on time, never exact values, with room for a slow network.
- Test what must not happen. No retry of a 404, no retry of a POST without a key, no network call while the breaker is open. These are the bugs that cause duplicate charges and retry storms.
- Keep slow tests apart.Run unit tests and fakes on every change, and the real-failure suite in CI or before a release. The REST tutorial's lesson on running tests in CI covers splitting suites.
- Only inject failures where you're allowed to.Use test endpoints like these, a local mock, or your own staging environment. Hammering someone else's production service to see what happens is not testing.
What to test in any API client
| Inject | Check that the client |
|---|---|
| A server that never answers | Gives up within its deadline, with a timeout error |
| Two or three 503s, then success | Recovers, and logs or counts the retries |
| A 503 that never stops | Gives up after its maximum attempts, within a known total time |
| A 404 or 400 | Doesn't retry |
| A 503 on a POST without an idempotency key | Doesn't retry |
| A 429 with Retry-After | Waits that long, or gives up at once if the wait is too long |
| A cut-off body | Reports an error instead of returning half the data |
| A non-JSON error page | Reports the status instead of a JSON parse error |
| A long outage | Stops calling (circuit breaker) and uses its fallback |
| A timeout on a payment | Retries with the same idempotency key and ends up with one payment |
Check your understanding
1.Why can't you rely on everyday use to find bugs in error handling?Show answer
Error handling only runs when something fails, which is rare. Bugs in it stay hidden until a real outage.
2.Why does the client read the response body inside the attempt?Show answer
So a body that gets cut off counts as a failed attempt and can be retried. Read later, the error would escape the retry loop.
3.Why does the timeout test allow 4 seconds instead of asserting 2.2?Show answer
Real timings vary with the network and the machine. A bound still catches the bug, since a client without a timeout would take 10 seconds, without failing at random.
4.When should a test use /api/chaos/flaky with failRate=0.5?Show answer
Rarely in an automated suite, because it's random. Use
retry/{key}for tests that must give the same result every run, and keep random failures for demos and soak tests.
Exercise
Add two tests to test/client.test.mjs from the checklist. First, a non-JSON error: the client gets an HTML or plain-text body with a 5xx status, which you can produce with /api/chaos/abortsign in to see through the public address. What does response.body contain, and does the client throw? Second, a POST with an idempotency key that fails twice with 503 and then succeeds, using /api/chaos/retry/{key}sign in to see: check that it's retried, then remove the key and check that it isn't.