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.
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.
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 });
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 }); });
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 });
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]); });
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); }); });