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.

Why the interval, not the count, is the thing to test Three timelines share one axis. Immediate retry fires four attempts within the first fifty milliseconds, stacking load on a failing dependency. Fixed interval spaces four attempts evenly at 300 milliseconds. Exponential backoff with jitter spaces them at roughly 100, 250 and 700 milliseconds with a shaded band around each showing the jitter range. All three eventually succeed, so an assertion on the final result cannot distinguish them. Immediate retry — all four attempts inside 50 ms succeeds — and quadruples load on a dependency that is already failing Fixed interval — evenly spaced, still synchronised across clients 300 ms 300 ms 300 ms every client in the fleet retries in lockstep Exponential with full jitter — growing gaps, desynchronised 0–200 ms 0–400 ms 0–800 ms shaded bands are the jitter range each attempt is drawn from All three timelines end in a success. Only the spacing tells you which client you actually shipped.

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.

The attempt log as the test's instrument A sequence between three lanes: the client, the mock handler, and the attempt log. Each of three attempts flows from client to handler; the handler appends a timestamped entry to the log and returns either a 503 or, on the third attempt, the order payload. The test then reads the log to assert the count, the intervals, and the headers of each attempt. Client Mock handler Attempt log attempt 1 — GET /orders/ord_1 push { at: t0 } 503 retryable: true attempt 2 — after 0–200 ms push { at: t1 } 503 retryable: true attempt 3 — after 0–400 ms push { at: t2 } 200 order payload the test reads count, gaps and headers from here

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.

What each response condition should do to the retry loop A five-row matrix. A 503 retries on the client's exponential schedule. A 429 retries but must wait the interval named in Retry-After. A 404 or 400 must not retry at all. A transport failure retries on the exponential schedule. Exhausting the attempt budget must surface a terminal error rather than continuing. Each row names the assertion that proves it. Condition Correct behaviour Assertion that proves it 503 / 502 / 500 retry on the exponential schedule count === 3 and gaps grow 429 with Retry-After wait the stated interval, not your own gaps[0] >= 2000 404 / 400 / 403 do not retry — the request is wrong count === 1 transport failure retry — nothing reached the server count === 3, then resolves budget exhausted surface a terminal error, stop trying rejects, and count stops growing

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 real Date.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.

  • POST retries need idempotency, not just backoff. Retrying a create without an idempotency key can produce duplicate records. Have the mock assert that every retried POST carries the same Idempotency-Key header — 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.


← Back to Error & Latency Simulation