The challenge

Why retry logic is hard to test

Retry logic is the kind of code that looks simple, gets written once, and then never gets properly tested. The path that matters — the one where the first attempt fails and a retry succeeds — is hard to exercise reliably. You can't control when a real service fails, and returning a hardcoded error from a mock means your test always fails or always succeeds. Neither tells you that your retry logic actually works under realistic conditions.

The other problem: retry logic interacts with latency. Exponential backoff only makes sense if retries are genuinely slow. A test where every call completes in 0ms can't tell you whether your backoff delays are too short, too long, or correctly jittered.

Common mistake

Testing retries with a mock that always succeeds on the second attempt is not testing retry logic — it's testing that your code calls the function twice when the first call returns an error you hardcoded.

Basics

Using errorRate to force failures

slowdep's errorRate option injects errors probabilistically. Set it to 0.5 and half of all calls will throw. This lets you write tests where your retry logic is exercised naturally — the first attempt might fail, or the second, or both. With enough test runs (or a high enough errorRate), you cover the path reliably.

import { withLatency } from 'slowdep';

const flakyClient = withLatency(realClient.query, {
  p50: 5,
  p99: 50,
  errorRate: 0.5,   // 50% of calls throw
});

// assert that your retry wrapper handles failures
it('retries on transient failure', async () => {
  let attempts = 0;
  const tracked = async (...args) => {
    attempts++;
    return flakyClient(...args);
  };

  // run many times to ensure retries are exercised
  const results = await Promise.allSettled(
    Array.from({ length: 20 }, () => withRetry(tracked, 3))
  );

  // with 50% error rate and 20 calls, retries definitely fired
  expect(attempts).toBeGreaterThan(20); // more attempts than calls = retries happened
});
Backoff

Testing exponential backoff

Exponential backoff requires testing that delays actually increase between retries. Combine slowdep with jest.useFakeTimers() to control the clock and assert that setTimeout was called with increasing delays.

import { withLatency } from 'slowdep';

describe('exponential backoff', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  it('doubles delay on each retry', async () => {
    const alwaysFails = withLatency(
      async () => { throw new Error('service unavailable'); },
      { p50: 5, p99: 50, errorRate: 1.0 }
    );

    const delays: number[] = [];
    const originalSetTimeout = global.setTimeout;
    jest.spyOn(global, 'setTimeout').mockImplementation((fn, delay) => {
      delays.push(delay as number);
      return originalSetTimeout(fn, 0); // don't actually wait
    });

    await expect(withRetry(alwaysFails, 3)).rejects.toThrow();
    expect(delays[0]).toBeLessThan(delays[1]);  // 100ms < 200ms
    expect(delays[1]).toBeLessThan(delays[2]);  // 200ms < 400ms
  });
});
Limits

Testing max retry limits

Set errorRate: 1.0 to guarantee every call fails. This creates a deterministic test that your retry wrapper gives up after exactly N attempts — not N+1, not forever, but the precise limit you configured.

it('gives up after 3 attempts', async () => {
  let callCount = 0;
  const alwaysFails = withLatency(
    async () => {
      callCount++;
      throw new Error('permanent failure');
    },
    { p50: 5, p99: 50, errorRate: 1.0 }
  );

  await expect(withRetry(alwaysFails, { maxAttempts: 3 }))
    .rejects.toThrow('permanent failure');

  expect(callCount).toBe(3); // exactly 3, no more
});
Jitter

Testing jitter in backoff

Without jitter, all retrying clients retry at exactly the same time — a thundering herd. A good backoff implementation randomizes the delay. Test this by verifying that consecutive retry delays differ, even when starting from the same failure.

it('uses different delays across retry attempts', async () => {
  const alwaysFails = withLatency(
    async () => { throw new Error('fail'); },
    { p50: 1, p99: 5, errorRate: 1.0 }
  );

  const collectedDelays: number[][] = [];

  // run the retry logic twice independently
  for (let i = 0; i < 2; i++) {
    const run: number[] = [];
    jest.spyOn(global, 'setTimeout').mockImplementation((fn, d) => {
      run.push(d as number);
      return global.setTimeout(fn, 0);
    });
    await expect(withRetry(alwaysFails, 2)).rejects.toThrow();
    collectedDelays.push(run);
    jest.restoreAllMocks();
  }

  // delays across runs should not be identical (jitter present)
  expect(collectedDelays[0][0]).not.toBe(collectedDelays[1][0]);
});
Full example

A complete fetchWithRetry test suite

Here's a fetchWithRetry implementation and a full test suite exercising success, partial failure, and exhausted retries — all using slowdep.

// fetchWithRetry.ts
export async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  options: { maxAttempts: number; baseDelay: number } = { maxAttempts: 3, baseDelay: 100 }
): Promise<T> {
  let lastError: Error;
  for (let attempt = 0; attempt < options.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err as Error;
      if (attempt < options.maxAttempts - 1) {
        const jitter = 0.5 + Math.random() * 0.5;
        const delay = options.baseDelay * ((2 ** attempt) * jitter);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }
  throw lastError!;
}
// fetchWithRetry.test.ts
import { withLatency } from 'slowdep';
import { fetchWithRetry } from './fetchWithRetry';

describe('fetchWithRetry', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  it('succeeds on first attempt when no errors', async () => {
    const fn = withLatency(async () => 'ok', { p50: 5, p99: 50, errorRate: 0 });
    const result = await fetchWithRetry(fn);
    expect(result).toBe('ok');
  });

  it('retries and eventually succeeds with partial error rate', async () => {
    let calls = 0;
    // errorRate 0.7 means ~30% chance of success per call
    // with 3 attempts: (1 - 0.7^3) = 97% chance of overall success
    const fn = withLatency(
      async () => { calls++; return 'data'; },
      { p50: 5, p99: 50, errorRate: 0.7 }
    );
    jest.spyOn(global, 'setTimeout').mockImplementation((fn) =>
      globalThis.setTimeout(fn, 0)
    );
    const results = await Promise.allSettled(
      Array.from({ length: 50 }, () => fetchWithRetry(fn))
    );
    const fulfilled = results.filter(r => r.status === 'fulfilled');
    expect(fulfilled.length).toBeGreaterThan(40); // most succeed via retry
    expect(calls).toBeGreaterThan(50);             // retries happened
  });

  it('gives up after maxAttempts', async () => {
    let attempts = 0;
    const fn = withLatency(
      async () => { attempts++; throw new Error('down'); },
      { p50: 5, p99: 50, errorRate: 1.0 }
    );
    jest.spyOn(global, 'setTimeout').mockImplementation((fn) =>
      globalThis.setTimeout(fn, 0)
    );
    await expect(fetchWithRetry(fn, { maxAttempts: 3, baseDelay: 100 }))
      .rejects.toThrow('down');
    expect(attempts).toBe(3);
  });
});