import { describe, expect, test, vi } from 'vitest'; import type { ExponentialBackoffCallbackParameters, ExponentialBackoffOptions, ExponentialBackoffRunOptions, } from '../../source/exponential-backoff/index.ts'; import { ExponentialBackoffMaxRetriesHitError, ExponentialBackoffNumberNotFiniteError, ExponentialBackoffStoppedRetriesError, } from '../../source/exponential-backoff/index.ts'; /** * The common run interface implemented by ExponentialBackoff and ExponentialBackoffExternallyAbortable. */ export type ExponentialBackoffInstance = { run: ( taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise, options?: Partial, ) => Promise; }; /** * Factory function passed into the tests to create an {@link ExponentialBackoffInstance}. */ export type ExponentialBackoffFactory = (options?: Partial) => ExponentialBackoffInstance; /** * Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs. */ const testExponentialBackoffSucceedsOnFirstAttempt = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { // Always resolves — never enters the catch/retry branch. const resolveFn = vi.fn(async () => 'success'); const onError = vi.fn(); const options = { baseDelay: 0, jitter: 0, }; const result = await exponentialBackoffFactory(options).run(resolveFn, { onError }); expect(result).toBe('success'); expect(resolveFn).toHaveBeenCalledOnce(); expect(onError).not.toHaveBeenCalled(); }; /** * Tests that retries continue across multiple failures until the function eventually resolves. */ const testExponentialBackoffRetriesUntilSuccess = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { // Three invocations: two rejections then a success on the third call. const tripleRejectFn = 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 options = { baseDelay: 0, jitter: 0, maxAttempts: 5, }; const result = await exponentialBackoffFactory(options).run(tripleRejectFn); expect(result).toBe('success'); expect(tripleRejectFn).toHaveBeenCalledTimes(3); }; /** * Tests that the onError callback is invoked once for every failed attempt, including the last one * before an ExponentialBackoffMaxRetriesHitError is thrown to the caller. */ const testExponentialBackoffCallsOnErrorForEachFailure = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { const error = new Error('temporary failure'); // Always rejects with the same error — we will exhaust all attempts. const rejectFn = vi.fn().mockRejectedValue(error); const onError = vi.fn(); // maxAttempts: 3 means three tries total, all of which will fail. const options = { baseDelay: 0, jitter: 0, maxAttempts: 3, }; await expect(exponentialBackoffFactory(options).run(rejectFn, { onError })).rejects.toThrow(ExponentialBackoffMaxRetriesHitError); expect(onError).toHaveBeenCalledTimes(3); expect(onError).toHaveBeenCalledWith(error); }; /** * Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError * with every task error preserved in order on the cause. */ const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { const firstError = new Error('first'); const lastError = new Error('last'); // Two distinct errors so we can prove both are collected, not just the last one. const doubleRejectFn = vi.fn().mockRejectedValueOnce(firstError) .mockRejectedValueOnce(lastError); try { const options = { baseDelay: 0, jitter: 0, maxAttempts: 2, }; await exponentialBackoffFactory(options).run(doubleRejectFn, { onError: vi.fn() }); expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError); expect((error as ExponentialBackoffMaxRetriesHitError).cause).toEqual([ firstError, lastError ]); } expect(doubleRejectFn).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 (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { // Reject with a plain string — not an Error subclass. const rejectedFn = vi.fn().mockRejectedValue('not-an-error'); const onError = vi.fn(); // Single attempt — we fail fast and inspect what onError received. try { const options = { baseDelay: 0, jitter: 0, maxAttempts: 1, }; await exponentialBackoffFactory(options).run(rejectedFn, { onError }); expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError); const [ wrappedError ] = (error as ExponentialBackoffMaxRetriesHitError).cause as Error[]; expect(wrappedError).toBeInstanceOf(Error); expect(wrappedError.message).toBe('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 that when the task function succeeds and the abort signal is aborted, the result is returned * and the onError callback is not called. */ const testExponentialBackoffRunSuccessAndAbortSignal = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { // Define the function which aborts the exponential backoff and succeeds const abortAndSucceedFn = vi.fn(({ stopRetries }) => { stopRetries(new Error('retry me')); return Promise.resolve('success'); }); const onErrorFn = vi.fn(); // Run the exponential backoff with the function and the onError callback const options = { baseDelay: 0, jitter: 0, }; const result = await exponentialBackoffFactory(options).run(abortAndSucceedFn, { onError: onErrorFn }); // Expect the result to be the success message expect(result).toBe('success'); expect(abortAndSucceedFn).toHaveBeenCalledOnce(); // Expect the onError callback to not have been called expect(onErrorFn).not.toHaveBeenCalled(); }; /** * Tests that when the abort signal is aborted with an error, an ExponentialBackoffStoppedRetriesError is thrown * with the error as the message. */ const testExponentialBackoffRunWithAbortSignal = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { // Define the function which aborts the exponential backoff and throws an error const abortAndThrowFn = vi.fn(({ stopRetries }) => { stopRetries(new Error('exponential backoff aborted')); throw new Error('error message'); }); const onErrorFn = vi.fn(); // Define the expected error const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted')); // Run the exponential backoff with the function and the onError callback and expect the error to be thrown const options = { baseDelay: 0, jitter: 0, }; await expect(exponentialBackoffFactory(options).run(abortAndThrowFn, { onError: onErrorFn })).rejects.toThrow(expectedError); // Expect the onError callback to have been called once with the error expect(onErrorFn).toHaveBeenCalledOnce(); expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error); expect(onErrorFn.mock.calls[0][0].message).toBe('error message'); // Expect the function to have been called once and not to have resolved expect(abortAndThrowFn).toHaveBeenCalledOnce(); expect(abortAndThrowFn).not.toHaveResolved(); }; /** * Tests that when the abort signal is aborted with a string, an ExponentialBackoffStoppedRetriesError is thrown * with the string as the message. */ const testExponentialBackoffRunAbortedStringCreatesError = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { // Define the function which aborts the exponential backoff and throws an error const abortAndThrowStringFn = vi.fn(({ stopRetries }) => { stopRetries('exponential backoff aborted'); // eslint-disable-next-line throw 'error message'; }); const onErrorFn = vi.fn(); // Define the expected error, Note that we "stopRetries" with just a string, not an error. They are treated equivalently. const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted')); // Run the exponential backoff with the function and the onError callback and expect the error to be thrown const options = { baseDelay: 0, jitter: 0, }; await expect(exponentialBackoffFactory(options).run(abortAndThrowStringFn, { onError: onErrorFn })).rejects.toThrow(expectedError); // Expect the onError callback to have been called once with the error expect(onErrorFn).toHaveBeenCalledOnce(); expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error); expect(onErrorFn.mock.calls[0][0].message).toBe('error message'); // Expect the function to have been called once and not to have resolved expect(abortAndThrowStringFn).toHaveBeenCalledOnce(); expect(abortAndThrowStringFn).not.toHaveResolved(); }; /** * Tests that the delay is aborted when the abort signal is activated. */ const testExponentialBackoffSignalInterruptsDelay = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { vi.useFakeTimers(); try { let abort: (reason: unknown) => void; const taskFn = vi.fn(async ({ stopRetries }) => { abort = stopRetries; throw new Error('error message'); }); // Start the exponential backoff and wait for it to complete const options = { baseDelay: 1000, jitter: 0, maxAttempts: 0, }; const result = exponentialBackoffFactory(options).run(taskFn, { onError: vi.fn() }); // Advance the timer by 500 (mid delay) await vi.advanceTimersByTimeAsync(500); // Make sure the abort function is defined (That the taskFn was called) if (!abort!) { throw new Error('abort is not defined'); } // Check that the abort function is defined expect(abort).toBeDefined(); // Abort the exponential backoff abort?.(new Error('exponential backoff aborted')); // Expect the result to be rejected with an ExponentialBackoffStoppedRetriesError await expect(result).rejects.toThrow(ExponentialBackoffStoppedRetriesError); } finally { vi.useRealTimers(); } }; /** * Tests that the exponential backoff is cancelled if the abort signal was activated before run() */ const testExponentialBackoffAlreadyAbortedSignalPreventsRun = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { const abortController = new AbortController(); const abortSignal = abortController.signal; abortController.abort(new Error('already aborted')); const taskFn = vi.fn(); const options = { baseDelay: 0, jitter: 0, }; const promise = exponentialBackoffFactory(options).run(taskFn, { onError: vi.fn(), signal: abortSignal }); await expect(promise).rejects.toThrow(ExponentialBackoffStoppedRetriesError); expect(taskFn).not.toHaveBeenCalled(); }; /** * Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds. */ const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (factory: ExponentialBackoffFactory): Promise => { // Four invocations: three failures then success — would exceed a cap of 3 if one existed. const tripleRejectThenResolveFn = vi .fn() .mockRejectedValueOnce(new Error('attempt 1')) .mockRejectedValueOnce(new Error('attempt 2')) .mockRejectedValueOnce(new Error('attempt 3')) .mockResolvedValueOnce('eventually'); const options = { baseDelay: 0, jitter: 0, maxAttempts: 0, }; const result = await factory(options).run(tripleRejectThenResolveFn, { onError: vi.fn() }); expect(result).toBe('eventually'); expect(tripleRejectThenResolveFn).toHaveBeenCalledTimes(4); }; /** * Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds * (with jitter disabled so the math is exact). */ const testExponentialBackoffIncreasesDelayExponentially = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { vi.useFakeTimers(); vi.spyOn(Math, 'random').mockReturnValue(0.5); try { const doubleRejectThenResolveFn = vi .fn() .mockRejectedValueOnce(new Error('attempt 1')) .mockRejectedValueOnce(new Error('attempt 2')) .mockResolvedValueOnce('success'); const options = { baseDelay: 100, growthRate: 2, jitter: 0, maxDelay: 10_000, maxAttempts: 5, }; const promise = exponentialBackoffFactory(options).run(doubleRejectThenResolveFn, { onError: vi.fn() }); // Attempt 0 fires synchronously on the first microtask tick. await Promise.resolve(); expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1); // After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1. await vi.advanceTimersByTimeAsync(100); expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2); // After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2. await vi.advanceTimersByTimeAsync(200); expect(doubleRejectThenResolveFn).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 (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { vi.useFakeTimers(); vi.spyOn(Math, 'random').mockReturnValue(0.5); try { const doubleRejectThenResolveFn = vi .fn() .mockRejectedValueOnce(new Error('attempt 1')) .mockRejectedValueOnce(new Error('attempt 2')) .mockResolvedValueOnce('success'); const options = { baseDelay: 1_000, growthRate: 4, jitter: 0, maxDelay: 2_000, maxAttempts: 5, }; const promise = exponentialBackoffFactory(options).run(doubleRejectThenResolveFn, { onError: vi.fn() }); await Promise.resolve(); expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1); // attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap. await vi.advanceTimersByTimeAsync(1_000); expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2); // attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms. await vi.advanceTimersByTimeAsync(2_000); expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3); await expect(promise).resolves.toBe('success'); } finally { vi.useRealTimers(); vi.restoreAllMocks(); } }; /** * Tests that jitter subtracts up to jitter * cappedDelay from the capped delay based on Math.random. */ const testExponentialBackoffAppliesJitter = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise => { vi.useFakeTimers(); // random = 1 → full 10% reduction: 1000 - (1 * 0.1 * 1000) = 900ms. vi.spyOn(Math, 'random').mockReturnValue(1); try { const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1')) .mockResolvedValueOnce('success'); const options = { baseDelay: 1_000, growthRate: 1, jitter: 0.1, maxDelay: 10_000, maxAttempts: 3, }; const promise = exponentialBackoffFactory(options).run(rejectThenResolveFn, { onError: vi.fn() }); await Promise.resolve(); expect(rejectThenResolveFn).toHaveBeenCalledTimes(1); // Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry. await vi.advanceTimersByTimeAsync(899); expect(rejectThenResolveFn).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); expect(rejectThenResolveFn).toHaveBeenCalledTimes(2); await expect(promise).resolves.toBe('success'); } finally { vi.useRealTimers(); vi.restoreAllMocks(); } }; /** * Tests that passing undefined into the constructor does not cause an error during spread */ const testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread = async (factory: ExponentialBackoffFactory): Promise => { const options = { baseDelay: undefined, growthRate: undefined, jitter: undefined, maxDelay: undefined, maxAttempts: undefined, }; // We expect an error during validation as undefined is not a finite number, not an issue with the spread operator // @ts-expect-error - Passing undefined is allowed if the exactOptionalPropertyTypes option is set to false in TS Compiler options. expect(() => factory(options)).toThrow(ExponentialBackoffNumberNotFiniteError); }; export const testExponentialBackoff = (name: string, exponentialBackoffFactory: ExponentialBackoffFactory): void => { describe(name, () => { test('ExponentialBackoff: returns the result on first success', () => testExponentialBackoffSucceedsOnFirstAttempt(exponentialBackoffFactory)); test('ExponentialBackoff: retries until the function succeeds', () => testExponentialBackoffRetriesUntilSuccess(exponentialBackoffFactory)); test('ExponentialBackoff: calls onError for each failed attempt', () => testExponentialBackoffCallsOnErrorForEachFailure(exponentialBackoffFactory)); test('ExponentialBackoff: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted', () => testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted(exponentialBackoffFactory)); test('ExponentialBackoff: wraps non-Error throws before calling onError', () => testExponentialBackoffWrapsNonErrorThrows(exponentialBackoffFactory)); test('ExponentialBackoff: succeeds and aborts with abort signal', () => testExponentialBackoffRunSuccessAndAbortSignal(exponentialBackoffFactory)); test('ExponentialBackoff: aborts with abort signal', () => testExponentialBackoffRunWithAbortSignal(exponentialBackoffFactory)); test('ExponentialBackoff: aborts with aborted string creates error', () => testExponentialBackoffRunAbortedStringCreatesError(exponentialBackoffFactory)); test('ExponentialBackoff: aborts with abort signal, interrupts delay', () => testExponentialBackoffSignalInterruptsDelay(exponentialBackoffFactory)); test('ExponentialBackoff: already aborted signal prevents run', () => testExponentialBackoffAlreadyAbortedSignalPreventsRun(exponentialBackoffFactory)); test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', () => testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero(exponentialBackoffFactory)); test('ExponentialBackoff: increases delay exponentially between attempts', () => testExponentialBackoffIncreasesDelayExponentially(exponentialBackoffFactory)); test('ExponentialBackoff: caps delay at maxDelay', () => testExponentialBackoffCapsDelayAtMaxDelay(exponentialBackoffFactory)); test('ExponentialBackoff: subtracts jitter from the capped delay', () => testExponentialBackoffAppliesJitter(exponentialBackoffFactory)); test('ExponentialBackoff: constructor does not cause an error during spread', () => testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread(exponentialBackoffFactory)); }); };