import { expect, test, vi } from 'vitest'; import { ExponentialBackoff } from '../source/exponential-backoff.ts'; /** * Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance * with library defaults (including the default 1000ms base delay) when no options are passed. */ const testExponentialBackoffRunUsesDefaultOptions = async (): Promise => { // Fake timers let us advance time without waiting real seconds between retries. vi.useFakeTimers(); // Pin Math.random so jitter does not randomize the delay we are about to measure. vi.spyOn(Math, 'random').mockReturnValue(0.5); try { // The wrapped function fails on its first invocation and succeeds on the second. // That forces ExponentialBackoff.run down the retry path using default options. const fn = vi.fn().mockRejectedValueOnce(new Error('retry me')) .mockResolvedValueOnce('static-result'); // Call the static helper with no onError and no options — defaults apply entirely. const promise = ExponentialBackoff.run(fn); // Yield one microtask so the first (immediate) attempt completes and schedules the retry timer. await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(1); // Default baseDelay is 1000ms; advancing less would not trigger the retry yet. await vi.advanceTimersByTimeAsync(1_000); // The retry should have succeeded and returned the resolved value from the mock. await expect(promise).resolves.toBe('static-result'); expect(fn).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); vi.restoreAllMocks(); } }; /** * Tests that {@link ExponentialBackoff.run} accepts a partial options object and merges it * with defaults, still retrying when only some fields are overridden. */ const testExponentialBackoffRunWithPartialOptions = async (): Promise => { // Same fail-then-succeed pattern; we only care that partial options still enable a retry. const fn = vi.fn().mockRejectedValueOnce(new Error('retry me')) .mockResolvedValueOnce('done'); // baseDelay/jitter of 0 skip real waiting; maxAttempts: 3 gives headroom for one retry. // onError is explicitly undefined to verify the default no-op handler is used. const result = await ExponentialBackoff.run(fn, undefined, { baseDelay: 0, jitter: 0, maxAttempts: 3, }); expect(result).toBe('done'); expect(fn).toHaveBeenCalledTimes(2); }; /** * Tests that calling {@link ExponentialBackoff.run} on a constructed instance applies * the instance's stored options when no per-run options are supplied. */ const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise => { const fn = vi.fn().mockRejectedValueOnce(new Error('retry me')) .mockResolvedValueOnce('instance-result'); // Options live on the instance; run(fn) should read them instead of static defaults. const backoff = new ExponentialBackoff({ baseDelay: 0, jitter: 0, maxAttempts: 3 }); const result = await backoff.run(fn); expect(result).toBe('instance-result'); expect(fn).toHaveBeenCalledTimes(2); }; /** * Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs. */ const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise => { // Always resolves — never enters the catch/retry branch. const fn = vi.fn(async () => 'success'); const onError = vi.fn(); const result = await ExponentialBackoff.run(fn, onError, { baseDelay: 0, jitter: 0, }); expect(result).toBe('success'); expect(fn).toHaveBeenCalledOnce(); expect(onError).not.toHaveBeenCalled(); }; /** * Tests that retries continue across multiple failures until the function eventually resolves. */ const testExponentialBackoffRetriesUntilSuccess = async (): Promise => { // Three invocations: two rejections then a success on the third call. const fn = vi.fn().mockRejectedValueOnce(new Error('attempt 1')) .mockRejectedValueOnce(new Error('attempt 2')) .mockResolvedValueOnce('success'); // maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap. const result = await ExponentialBackoff.run(fn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 5, }); expect(result).toBe('success'); expect(fn).toHaveBeenCalledTimes(3); }; /** * Tests that the onError callback is invoked once for every failed attempt, including the last one * before the final rejection is thrown to the caller. */ const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise => { const error = new Error('temporary failure'); // Always rejects with the same error — we will exhaust all attempts. const fn = vi.fn().mockRejectedValue(error); const onError = vi.fn(); // maxAttempts: 3 means three tries total, all of which will fail. await expect(ExponentialBackoff.run(fn, onError, { baseDelay: 0, jitter: 0, maxAttempts: 3, })).rejects.toThrow('temporary failure'); expect(onError).toHaveBeenCalledTimes(3); expect(onError).toHaveBeenCalledWith(error); }; /** * Tests that when all attempts are exhausted the caller receives the error from the final attempt, * not an earlier one. */ const testExponentialBackoffThrowsLastErrorWhenExhausted = async (): Promise => { const firstError = new Error('first'); const lastError = new Error('last'); // Two distinct errors so we can prove the last one surfaces. const fn = vi.fn().mockRejectedValueOnce(firstError) .mockRejectedValueOnce(lastError); await expect(ExponentialBackoff.run(fn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 2, })).rejects.toThrow('last'); expect(fn).toHaveBeenCalledTimes(2); }; /** * Tests that rejections which are not Error instances are coerced to Error before onError runs, * so callers always observe a consistent error type in the callback. */ const testExponentialBackoffWrapsNonErrorThrows = async (): Promise => { // Reject with a plain string — not an Error subclass. const fn = vi.fn().mockRejectedValue('not-an-error'); const onError = vi.fn(); // Single attempt — we fail fast and inspect what onError received. await expect(ExponentialBackoff.run(fn, onError, { baseDelay: 0, jitter: 0, maxAttempts: 1, })).rejects.toThrow('not-an-error'); expect(onError).toHaveBeenCalledOnce(); expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); expect(onError.mock.calls[0][0].message).toBe('not-an-error'); }; /** * Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run} * as an alternative to the static helper. */ const testExponentialBackoffFromAndInstanceRun = async (): Promise => { const fn = vi.fn(async () => 42); // from() is a convenience constructor; run() on the result should behave like the static path. const backoff = ExponentialBackoff.from({ baseDelay: 0, jitter: 0, }); const result = await backoff.run(fn); expect(result).toBe(42); expect(fn).toHaveBeenCalledOnce(); }; /** * Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds. */ const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (): Promise => { // Four invocations: three failures then success — would exceed a cap of 3 if one existed. const fn = vi .fn() .mockRejectedValueOnce(new Error('attempt 1')) .mockRejectedValueOnce(new Error('attempt 2')) .mockRejectedValueOnce(new Error('attempt 3')) .mockResolvedValueOnce('eventually'); const result = await ExponentialBackoff.run(fn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 0, }); expect(result).toBe('eventually'); expect(fn).toHaveBeenCalledTimes(4); }; /** * Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds * (with jitter disabled so the math is exact). */ const testExponentialBackoffIncreasesDelayExponentially = async (): Promise => { vi.useFakeTimers(); vi.spyOn(Math, 'random').mockReturnValue(0.5); try { const fn = vi .fn() .mockRejectedValueOnce(new Error('attempt 1')) .mockRejectedValueOnce(new Error('attempt 2')) .mockResolvedValueOnce('success'); const promise = ExponentialBackoff.run(fn, () => {}, { baseDelay: 100, growthRate: 2, jitter: 0, maxDelay: 10_000, maxAttempts: 5, }); // Attempt 0 fires synchronously on the first microtask tick. await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(1); // After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1. await vi.advanceTimersByTimeAsync(100); expect(fn).toHaveBeenCalledTimes(2); // After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2. await vi.advanceTimersByTimeAsync(200); expect(fn).toHaveBeenCalledTimes(3); await expect(promise).resolves.toBe('success'); } finally { vi.useRealTimers(); vi.restoreAllMocks(); } }; /** * Tests that computed delay never exceeds maxDelay even when exponential growth would go higher. */ const testExponentialBackoffCapsDelayAtMaxDelay = async (): Promise => { vi.useFakeTimers(); vi.spyOn(Math, 'random').mockReturnValue(0.5); try { const fn = vi .fn() .mockRejectedValueOnce(new Error('attempt 1')) .mockRejectedValueOnce(new Error('attempt 2')) .mockResolvedValueOnce('success'); const promise = ExponentialBackoff.run(fn, () => {}, { baseDelay: 1_000, growthRate: 4, jitter: 0, maxDelay: 2_000, maxAttempts: 5, }); await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(1); // attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap. await vi.advanceTimersByTimeAsync(1_000); expect(fn).toHaveBeenCalledTimes(2); // attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms. await vi.advanceTimersByTimeAsync(2_000); expect(fn).toHaveBeenCalledTimes(3); await expect(promise).resolves.toBe('success'); } finally { vi.useRealTimers(); vi.restoreAllMocks(); } }; /** * Tests that jitter scales the capped delay up or down by jitter * cappedDelay based on Math.random. */ const testExponentialBackoffAppliesJitter = async (): Promise => { vi.useFakeTimers(); // random = 1 → jitterDirection = 2*1 - 1 = +1 → full positive 10% jitter on the delay. vi.spyOn(Math, 'random').mockReturnValue(1); try { const fn = vi.fn().mockRejectedValueOnce(new Error('attempt 1')) .mockResolvedValueOnce('success'); const promise = ExponentialBackoff.run(fn, () => {}, { baseDelay: 1_000, growthRate: 1, jitter: 0.1, maxDelay: 10_000, maxAttempts: 3, }); await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(1); // cappedDelay = 1000; with +10% jitter the retry fires after 1100ms, not 1000ms. await vi.advanceTimersByTimeAsync(1_100); expect(fn).toHaveBeenCalledTimes(2); await expect(promise).resolves.toBe('success'); } finally { vi.useRealTimers(); vi.restoreAllMocks(); } }; const runTests = async (): Promise => { test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions); test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions); test('ExponentialBackoff.run: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError); test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt); test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess); test('ExponentialBackoff: calls onError for each failed attempt', testExponentialBackoffCallsOnErrorForEachFailure); test('ExponentialBackoff: throws the last error when max attempts are exhausted', testExponentialBackoffThrowsLastErrorWhenExhausted); test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows); test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun); test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero); test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially); test('ExponentialBackoff: caps delay at maxDelay', testExponentialBackoffCapsDelayAtMaxDelay); test('ExponentialBackoff: applies jitter around the capped delay', testExponentialBackoffAppliesJitter); }; await runTests();