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.

An exponential backoff schedule with a cap Delays double from 200 milliseconds to 400, 800 and 1600, then stop growing at a 2000 millisecond cap; jitter scales each delay by a random factor, which tests fix by injecting the random source. Delay before each retry (jitter factor fixed at 1) retry 1 200 ms retry 2 400 ms retry 3 800 ms retry 4 1600 ms retry 5 2000 ms — capped
Every bar is a number a test can assert exactly, once the clock and the random source are both under its control.

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]);
});
Two injected seams make retries deterministic Injecting the random source fixes jitter so delays are exact, and injecting the sleep function either records delays and resolves at once or cooperates with fake timers, so no real time passes and every attempt is observable. withRetry the policy under test random() fixed → exact delays sleep(ms) recorded → no real wait production passes Math.random and setTimeout; tests pass their own
Time and randomness are dependencies like any other; once injected, the policy is ordinary deterministic code.

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.

Retry bugs and the assertion that catches each An off-by-one in attempts is caught by counting calls, retrying a client error by asserting one call, an uncapped delay by asserting the schedule, and ignoring Retry-After by asserting the recorded delay. off-by-one count the calls retries a 400 assert one call no cap assert the schedule ignores Retry-After assert the delay
Each bug has a specific, cheap assertion — none of which requires real time to pass.

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.