Stubbing Retry and Backoff Logic Deterministically
Retry logic is one of the few places in a client where getting the details wrong causes real damage: too few attempts and transient failures reach users, too many and a struggling service is hammered back into an outage, no jitter and every client retries in lockstep. Yet retry code is usually tested either with real waits — making a single test take tens of seconds — or not at all. The obstacle is that backoff is time-based and jitter is random, both of which are hostile to deterministic tests. This guide covers making both controllable: injecting the random source, advancing fake timers through the backoff schedule, asserting on the exact delays and attempt counts, and honouring server-provided Retry-After hints. It sits under HTTP request stubbing techniques.
Root Cause Analysis
A retry policy has several parameters that interact: the maximum number of attempts, which failures are retryable, the base delay, the growth factor, the cap on any single delay, and the jitter that spreads clients apart. Bugs hide in each. An off-by-one gives three retries instead of two. A retry on a 400 wastes time on a request that can never succeed. A missing cap produces a delay of several minutes on the tenth attempt. Missing jitter synchronises thousands of clients into a thundering herd the moment a service recovers.
None of these is visible from the outcome of a single call, which either succeeds or fails. They are visible only in the sequence of attempts and the gaps between them — which is exactly what a real-time test cannot afford to observe, because a policy with a thirty-second budget needs a thirty-second test.
Randomness makes it worse. Full jitter draws each delay from a range, so the same test produces different timings every run. A test that asserts on timings becomes flaky; a test that avoids timings verifies nothing about the backoff. The way out is to treat the random source as a dependency, just like the clock.
Reproducible Setup
A retry helper whose sources of time and randomness are parameters, with production defaults.
// src/net/retry.ts
export type RetryPolicy = {
attempts: number; // total attempts, including the first
baseMs: number;
capMs: number;
retryable: (err: unknown) => boolean;
random?: () => number; // [0, 1); injected in tests
sleep?: (ms: number) => Promise<void>;
onRetry?: (attempt: number, delayMs: number) => void;
};
export const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function backoffDelay(retry: number, { baseMs, capMs }: RetryPolicy, random: () => number) {
const exp = Math.min(capMs, baseMs * 2 ** (retry - 1));
return Math.round(exp * random()); // full jitter
}
export async function withRetry<T>(fn: () => Promise<T>, policy: RetryPolicy): Promise<T> {
const random = policy.random ?? Math.random;
const sleep = policy.sleep ?? defaultSleep;
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt >= policy.attempts || !policy.retryable(err)) throw err;
const retryAfter = (err as { retryAfterMs?: number }).retryAfterMs;
const delay = retryAfter ?? backoffDelay(attempt, policy, random);
policy.onRetry?.(attempt, delay);
await sleep(delay);
}
}
}
Implementation
Step 1 — Test the schedule as pure arithmetic. The delay function has no side effects, so the whole schedule — growth, cap and jitter bounds — is checked in microseconds.
// src/net/retry.test.ts
import { test, expect } from 'vitest';
import { backoffDelay } from './retry';
const policy = { attempts: 6, baseMs: 200, capMs: 2000, retryable: () => true };
test('doubles from the base and stops at the cap', () => {
const noJitter = () => 1 - Number.EPSILON;
expect([1, 2, 3, 4, 5, 6].map((r) => backoffDelay(r, policy, noJitter))).toEqual([200, 400, 800, 1600, 2000, 2000]);
});
test('full jitter keeps every delay within zero and the unjittered value', () => {
expect(backoffDelay(3, policy, () => 0)).toBe(0);
expect(backoffDelay(3, policy, () => 0.5)).toBe(400);
});
Step 2 — Count attempts with a recorded sleep. Replacing sleep with a function that records the delay and resolves immediately makes the whole retry loop synchronous in effect and fully observable.
test('makes exactly the configured number of attempts, then rethrows', async () => {
const delays: number[] = [];
const fn = vi.fn().mockRejectedValue(new TransientError());
await expect(withRetry(fn, {
...policy, attempts: 4, random: () => 0.5,
sleep: async (ms) => { delays.push(ms); },
})).rejects.toBeInstanceOf(TransientError);
expect(fn).toHaveBeenCalledTimes(4);
expect(delays).toEqual([100, 200, 400]); // three waits between four attempts
});
Step 3 — Prove non-retryable failures fail fast. A client error should be attempted once, with no sleep at all.
test('does not retry a non-retryable error', async () => {
const sleep = vi.fn();
const fn = vi.fn().mockRejectedValue(new BadRequestError());
await expect(withRetry(fn, { ...policy, retryable: (e) => e instanceof TransientError, sleep }))
.rejects.toBeInstanceOf(BadRequestError);
expect(fn).toHaveBeenCalledOnce();
expect(sleep).not.toHaveBeenCalled();
});
Step 4 — Honour Retry-After from the server. When a 429 or 503 says how long to wait, the client should use that rather than its own schedule; MSW produces the header, and the recorded sleep proves it was respected.
import { http, HttpResponse } from 'msw';
test('waits for the server-specified Retry-After on a 429', async () => {
let calls = 0;
server.use(http.get('/api/quote', () =>
++calls === 1
? new HttpResponse(null, { status: 429, headers: { 'retry-after': '3' } })
: HttpResponse.json({ price: 42 }),
));
const delays: number[] = [];
await expect(getQuote({ sleep: async (ms) => { delays.push(ms); } })).resolves.toEqual({ price: 42 });
expect(delays).toEqual([3000]);
});
Step 5 — Use fake timers when the sleep cannot be injected. Third-party clients that call setTimeout internally can still be driven deterministically; advance the clock through each expected delay and check the attempt count at each step.
test('the SDK retries after its own backoff', async () => {
vi.useFakeTimers();
let calls = 0;
server.use(http.get('/api/items', () => (++calls < 3 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json([]))));
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const pending = sdk.listItems();
await vi.advanceTimersByTimeAsync(100); expect(calls).toBe(2);
await vi.advanceTimersByTimeAsync(200); expect(calls).toBe(3);
await expect(pending).resolves.toEqual([]);
vi.useRealTimers();
});
Step 6 — Surface retries to observability. The onRetry hook turns silent retries into log lines or metrics; test that it fires with the attempt number and delay, because a retry storm nobody can see is only diagnosed after the outage.
A useful discipline is to express the retry policy as data — attempts, base, cap, which errors are retryable — in one exported constant per client, and to test that constant directly alongside the behaviour. When someone changes the attempt count from three to ten during an incident, the diff shows the policy changing and a test states what it now means, rather than a magic number buried in a loop.
Verification
Confirm the attempt-count test catches an off-by-one: change the loop condition to attempt > policy.attempts and the test must report five calls instead of four.
npx vitest run src/net/retry.test.ts --reporter=verbose
# ✓ doubles from the base and stops at the cap
# ✓ full jitter keeps every delay within zero and the unjittered value
# ✓ makes exactly the configured number of attempts, then rethrows
# ✓ does not retry a non-retryable error
# ✓ waits for the server-specified Retry-After on a 429
Then confirm the whole suite takes milliseconds. If any retry test takes seconds, it is sleeping for real — find it and inject the sleep, or install fake timers.
Troubleshooting
Symptom: the retry test takes as long as the backoff. Diagnosis: the default sleep is running with real timers. Fix: inject a recording sleep, or install fake timers before the call and advance them.
Symptom: fake timers advance but no retry happens. Diagnosis: the retry awaits a promise between the failure and scheduling the timer, and synchronous advancing does not flush it. Fix: use advanceTimersByTimeAsync, which runs pending microtasks between timer callbacks.
Symptom: delays vary between runs. Diagnosis: jitter uses Math.random directly and the test did not control it. Fix: inject the random source, or spy on Math.random for the duration of the test and restore it afterwards.
Symptom: Retry-After given as an HTTP date is ignored. Diagnosis: the parser handles only the seconds form. Fix: accept both forms — the header may be an integer or a date — and test each, computing the date form relative to an injected clock.
FAQ
Which jitter strategy should I use?
Full jitter — a random delay between zero and the exponential value — spreads clients most effectively and is the common default. Equal jitter keeps a minimum wait. Whichever you choose, the test pattern is the same: fix the random source, assert the resulting delays.
Should retries live in the HTTP client or in business logic?
In one shared place, usually the HTTP client or a wrapper around it, so every call uses the same tested policy. Scattered ad-hoc retry loops each get the details subtly wrong, and none of them is tested.
How do retries interact with idempotency?
Only retry requests that are safe to repeat — reads, and writes carrying an idempotency key. Retrying a non-idempotent write can apply it twice; the key test in asserting request payloads without brittle snapshots checks the key stays the same across attempts.
What about circuit breakers?
A breaker stops retrying altogether after repeated failures, and it is tested the same way: inject the clock, drive failures until it opens, assert that calls are refused, advance past the cool-down, and assert that a trial call is allowed. The injected clock is what makes the cool-down testable.
Related
- Back to HTTP Request Stubbing Techniques
- Simulating network errors and timeouts with MSW — producing the failures retries respond to.
- Controlling Date.now and setTimeout in Jest — fake timers in depth.
- Testing background jobs queued with BullMQ — retries at the job level.