Testing Retry and Backoff Logic Locally
Your HTTP client is configured to retry three times with exponential backoff, and nobody has ever seen it do so. This page shows how to prove it — that the attempt count is right, the intervals grow as designed, Retry-After is honoured, non-retryable statuses are not retried, and the client eventually gives up instead of hammering a dying dependency.
Context: why the usual retry test proves nothing
The typical retry test asserts that the call eventually resolves. That assertion is satisfied by a correct client and by a catastrophically wrong one — a client that retries instantly, with no backoff, twenty times, still resolves. The interesting properties of retry logic are all temporal, and a test that only inspects the final value cannot observe any of them.
Worse, a naive retry is actively harmful. When a dependency is failing because it is overloaded, every client retrying immediately multiplies the load at the exact moment it should be falling. Backoff with jitter exists to prevent that synchronised stampede, and it is the part most likely to be misconfigured, because nothing in normal operation ever exercises it.
The mock is the only place you can observe attempts directly. Record them there and every temporal property becomes assertable.
Solution
1. Make the mock record every attempt
An attempt log is the instrument. Keep it beside the handler and reset it per spec:
// src/mocks/attempt-log.ts
export interface Attempt {
at: number; // Date.now() when the request reached the mock
method: string;
url: string;
headers: Record<string, string>;
}
const log: Attempt[] = [];
export function record(request: Request): number {
log.push({
at: Date.now(),
method: request.method,
url: request.url,
headers: Object.fromEntries(request.headers),
});
return log.length;
}
export const attempts = () => [...log];
export const attemptCount = () => log.length;
export const resetAttempts = () => { log.length = 0; };
/** Gaps in milliseconds between consecutive attempts. */
export function intervals(): number[] {
return log.slice(1).map((a, i) => a.at - log[i].at);
}
The handler uses record to both count and decide:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { record } from './attempt-log';
import { errorResponse } from './errors';
/** Fail the first FAIL_UNTIL attempts, then serve the real payload. */
const FAIL_UNTIL = Number(process.env.MOCK_FAIL_UNTIL ?? '2');
export const handlers = [
http.get('https://api.example.com/orders/:id', ({ request, params }) => {
const n = record(request);
if (n <= FAIL_UNTIL) return errorResponse(503, n);
return HttpResponse.json({ id: params.id, status: 'paid', total: 4250 });
}),
];
2. Assert the count and the recovery together
The two halves belong in one spec, because passing only the first is how a client that never retries slips through:
// src/features/orders/retry.test.ts
import { beforeEach, expect, it } from 'vitest';
import { attemptCount, resetAttempts } from '../../mocks/attempt-log';
import { fetchOrder } from './api';
beforeEach(() => resetAttempts());
it('retries a 503 twice and then succeeds', async () => {
const order = await fetchOrder('ord_1');
expect(attemptCount()).toBe(3); // 1 original + 2 retries
expect(order.status).toBe('paid'); // and it actually recovered
});
it('does not retry a 404', async () => {
resetAttempts();
await expect(fetchOrder('missing')).rejects.toThrow(/not_found/);
expect(attemptCount()).toBe(1); // retrying would be pointless load
});
The second spec is the one most suites lack. A client that retries 404 is not merely wasteful — it turns a clean “no such thing” into a slow, confusing failure, and nothing in normal use reveals it.
3. Assert the spacing with a controlled clock
Real backoff intervals in a unit test are unaffordable, so drive the clock and read the recorded gaps:
// src/features/orders/backoff.test.ts
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { intervals, resetAttempts } from '../../mocks/attempt-log';
import { fetchOrder } from './api';
beforeEach(() => { resetAttempts(); vi.useFakeTimers({ shouldAdvanceTime: true }); });
afterEach(() => vi.useRealTimers());
it('spaces retries on a growing exponential schedule', async () => {
const pending = fetchOrder('ord_1');
// Walk the clock past each expected backoff ceiling in turn.
await vi.advanceTimersByTimeAsync(200); // attempt 2 window: base 100, ceiling 200
await vi.advanceTimersByTimeAsync(400); // attempt 3 window: ceiling 400
await pending;
const gaps = intervals();
expect(gaps).toHaveLength(2);
expect(gaps[0]).toBeGreaterThan(0);
expect(gaps[0]).toBeLessThanOrEqual(200);
expect(gaps[1]).toBeGreaterThan(gaps[0]); // it grows
expect(gaps[1]).toBeLessThanOrEqual(400);
});
Assert on bands, never on exact values. With full jitter each delay is a random draw between zero and the exponential ceiling, so expect(gaps[0]).toBe(100) is guaranteed to flake. The two properties worth pinning are that each gap stays under its ceiling and that the ceilings grow — everything else is deliberately random.
4. Honour Retry-After on a 429
Rate limiting is the one case where the server dictates the interval, and ignoring it is what escalates a temporary limit into a block. Have the mock state an interval and assert the client waited at least that long:
it('waits at least the Retry-After interval on 429', async () => {
resetAttempts();
server.use(
http.get('https://api.example.com/orders/:id', ({ request }) => {
const n = record(request);
return n === 1
? HttpResponse.json({ error: 'rate_limited', retryable: true }, {
status: 429,
headers: { 'Retry-After': '2' },
})
: HttpResponse.json({ id: 'ord_1', status: 'paid' });
})
);
const pending = fetchOrder('ord_1');
await vi.advanceTimersByTimeAsync(2_000);
await pending;
expect(intervals()[0]).toBeGreaterThanOrEqual(2_000);
});
A client using its own 100 ms exponential schedule fails this spec immediately, which is exactly the point — the server’s instruction has to override the client’s default.
The last row is the one that matters most in an incident. A client with no attempt ceiling never surfaces the failure at all; it simply keeps trying while the user stares at a spinner, and the outage looks like a frontend hang rather than a dependency problem.
Verification
# Full retry behaviour, including the non-retryable and rate-limit cases
npx vitest run src/features/orders/retry.test.ts src/features/orders/backoff.test.ts
# The give-up path: more failures than the client has attempts
MOCK_FAIL_UNTIL=99 npx vitest run src/features/orders/retry.test.ts -t 'gives up'
The second run must fail the request rather than hang. If it hangs, the client has an unbounded retry loop — the single most dangerous outcome of this whole area, and one that only a permanently failing mock will reveal.
Gotchas and edge cases
-
Date.now()under fake timers is the fake clock. That is what makes the interval assertions work, but it also means the recorded timestamps are not wall-clock. Never compare them against a realDate.now()captured outside the faked region — the two clocks are unrelated and the comparison produces nonsense. -
Retries multiply against parallel requests. A page issuing six requests, each retrying three times, produces eighteen calls to a failing dependency. Assert the total attempt count across the page, not just per endpoint, or you will ship a client that is well behaved in isolation and a stampede in aggregate.
-
POSTretries need idempotency, not just backoff. Retrying a create without an idempotency key can produce duplicate records. Have the mock assert that every retriedPOSTcarries the sameIdempotency-Keyheader — the attempt log already captures headers, so it is one extra expectation, and it catches a class of bug that no amount of timing assertion will.
Related
- Returning HTTP 500 Errors on Demand — the failure source these retries are exercised against
- Simulating Network Latency in MSW — controlling time so backoff assertions stay fast
- Modeling CRUD State in a Mock Server — the state machine behind fail-then-recover sequences
← Back to Error & Latency Simulation