import { expect, test, vi } from 'vitest'; import { ExponentialBackoff } from '../../src/utils/exponential-backoff.js'; /** * A valid options object that satisfies {@link ExponentialBackoff.validateOptions}. */ const validExponentialBackoffOptions = { maxDelay: 10_000, maxAttempts: 10, baseDelay: 1_000, growthRate: 2, jitter: 0.1, }; /** * 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 to 0 so jitter does not reduce the default delay. vi.spyOn(Math, 'random').mockReturnValue(0); 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 rejectThenResolveFn = 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(rejectThenResolveFn); // Yield one microtask so the first (immediate) attempt completes and schedules the retry timer. await Promise.resolve(); expect(rejectThenResolveFn).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(rejectThenResolveFn).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 rejectThenResolveFn = 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(rejectThenResolveFn, undefined, { baseDelay: 0, jitter: 0, maxAttempts: 3, }); expect(result).toBe('done'); expect(rejectThenResolveFn).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 rejectThenResolveFn = 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(rejectThenResolveFn); expect(result).toBe('instance-result'); expect(rejectThenResolveFn).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 resolveFn = vi.fn(async () => 'success'); const onError = vi.fn(); const result = await ExponentialBackoff.run(resolveFn, onError, { baseDelay: 0, jitter: 0, }); 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 (): 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 result = await ExponentialBackoff.run(tripleRejectFn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 5, }); 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 (): 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. await expect(ExponentialBackoff.run(rejectFn, onError, { baseDelay: 0, jitter: 0, maxAttempts: 3, })).rejects.toThrow(AggregateError); expect(onError).toHaveBeenCalledTimes(3); expect(onError).toHaveBeenCalledWith(error, expect.objectContaining({})); }; /** * 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 (): 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 { await ExponentialBackoff.run(doubleRejectFn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 2, }); expect.fail('Expected AggregateError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(AggregateError); expect((error as AggregateError).errors).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 (): 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 { await ExponentialBackoff.run(rejectedFn, onError, { baseDelay: 0, jitter: 0, maxAttempts: 1, }); expect.fail('Expected AggregateError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(AggregateError); const [ wrappedError ] = (error as AggregateError).errors 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 (): Promise => { // Define the function which aborts the exponential backoff and succeeds const abortAndSucceedFn = vi.fn(({ abort }) => { abort(new Error('retry me')); return Promise.resolve('success'); }); const onErrorFn = vi.fn(); // Run the exponential backoff with the function and the onError callback const result = await ExponentialBackoff.run(abortAndSucceedFn, onErrorFn, { baseDelay: 0, jitter: 0, }); // 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 AggregateError is thrown * with the error as the message. */ const testExponentialBackoffRunWithAbortSignal = async (): Promise => { // Define the function which aborts the exponential backoff and throws an error const abortAndThrowFn = vi.fn(({ abort }) => { abort(new Error('exponential backoff aborted message')); throw new Error('error message'); }); const onErrorFn = vi.fn(); // Define the expected error const expectedError = new Error('Exponential backoff aborted', { cause: new Error('exponential backoff aborted message') }); // Run the exponential backoff with the function and the onError callback and expect the error to be thrown await expect(ExponentialBackoff.run(abortAndThrowFn, onErrorFn, { baseDelay: 0, jitter: 0, })).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 AggregateError is thrown * with the string as the message. */ const testExponentialBackoffRunAbortedStringCreatesError = async (): Promise => { // Define the function which aborts the exponential backoff and throws an error const abortAndThrowStringFn = vi.fn(({ abort }) => { abort('exponential backoff aborted message'); // eslint-disable-next-line throw 'error message'; }); const onErrorFn = vi.fn(); // Define the expected error, Note that we "abort" with just a string, not an error. They are treated equivalently. const expectedError = new Error('Exponential backoff aborted', { cause: new Error('exponential backoff aborted message') }); // Run the exponential backoff with the function and the onError callback and expect the error to be thrown await expect(ExponentialBackoff.run(abortAndThrowStringFn, onErrorFn, { baseDelay: 0, jitter: 0, })).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 the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run} * as an alternative to the static helper. */ const testExponentialBackoffFromAndInstanceRun = async (): Promise => { const successfullyResolve = 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(successfullyResolve); expect(result).toBe(42); expect(successfullyResolve).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 tripleRejectThenResolveFn = vi .fn() .mockRejectedValueOnce(new Error('attempt 1')) .mockRejectedValueOnce(new Error('attempt 2')) .mockRejectedValueOnce(new Error('attempt 3')) .mockResolvedValueOnce('eventually'); const result = await ExponentialBackoff.run(tripleRejectThenResolveFn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 0, }); 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 (): 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 promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, { baseDelay: 100, growthRate: 2, jitter: 0, maxDelay: 10_000, maxAttempts: 5, }); // 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 (): 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 promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, { baseDelay: 1_000, growthRate: 4, jitter: 0, maxDelay: 2_000, maxAttempts: 5, }); 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 (): 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 promise = ExponentialBackoff.run(rejectThenResolveFn, () => {}, { baseDelay: 1_000, growthRate: 1, jitter: 0.1, maxDelay: 10_000, maxAttempts: 3, }); 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 {@link ExponentialBackoff.validateOptions} accepts valid options, including boundary values of 0 and 1. */ const testExponentialBackoffValidateOptionsAcceptsValidOptions = (): void => { const validCases = [ validExponentialBackoffOptions, { ...validExponentialBackoffOptions, maxDelay: 0, maxAttempts: 0, baseDelay: 0, growthRate: 0, jitter: 0, }, { ...validExponentialBackoffOptions, jitter: 1, }, ] as const; for (const options of validCases) { expect(() => ExponentialBackoff.validateOptions(options)).not.toThrow(); } }; /** * Tests that {@link ExponentialBackoff.validateOptions} rejects negative numeric options. */ const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => { // Define our test cases with each value being less than 0 const negativeCases = [ { field: 'maxDelay', value: -1 }, { field: 'maxAttempts', value: -1 }, { field: 'baseDelay', value: -1 }, { field: 'growthRate', value: -1 }, ] as const; // Iterate through the test cases and expect an error to be thrown for (const { field, value } of negativeCases) { expect(() => ExponentialBackoff.validateOptions({ ...validExponentialBackoffOptions, [field]: value, })).toThrow(`Invalid option: ${field} is less than 0`); } }; /** * Tests that {@link ExponentialBackoff.validateOptions} rejects jitter below 0 or above 1. */ const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => { // Define our test cases with each value being less than 0 or greater than 1 const invalidJitterCases: Array<{ value: number }> = [{ value: -0.1 }, { value: 1.1 }]; // Iterate through the test cases and expect an error to be thrown for (const { value } of invalidJitterCases) { expect(() => ExponentialBackoff.validateOptions({ ...validExponentialBackoffOptions, jitter: value, })).toThrow('Invalid option: jitter is not between 0 and 1'); } }; /** * Tests that {@link ExponentialBackoff.validateOptions} rejects non-finite values such as Infinity. */ const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void => { // Define our test cases with each value being Infinity const nonFiniteCases = [ { field: 'maxDelay', value: Infinity }, { field: 'maxAttempts', value: Infinity }, { field: 'baseDelay', value: Infinity }, { field: 'growthRate', value: Infinity }, { field: 'jitter', value: Infinity }, ] as const; // Iterate through the test cases and expect an error to be thrown for (const { field, value } of nonFiniteCases) { expect(() => ExponentialBackoff.validateOptions({ ...validExponentialBackoffOptions, [field]: value, })).toThrow(`Invalid option: ${field} is not finite`); } }; /** * Tests that {@link ExponentialBackoff.validateOptions} rejects NaN, which is also non-finite. */ const testExponentialBackoffValidateOptionsRejectsNaN = (): void => { // Define our test cases with each value being NaN const nanCases = [ { field: 'maxDelay', value: Number.NaN }, { field: 'maxAttempts', value: Number.NaN }, { field: 'baseDelay', value: Number.NaN }, { field: 'growthRate', value: Number.NaN }, { field: 'jitter', value: Number.NaN }, ] as const; // Iterate through the test cases and expect an error to be thrown for (const { field, value } of nanCases) { expect(() => ExponentialBackoff.validateOptions({ ...validExponentialBackoffOptions, [field]: value, })).toThrow(`Invalid option: ${field} is not finite`); } }; 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 ExponentialBackoffMaxRetriesHitError when max attempts are exhausted', testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted, ); test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows); test('ExponentialBackoff: succeeds and aborts with abort signal', testExponentialBackoffRunSuccessAndAbortSignal); test('ExponentialBackoff: aborts with abort signal', testExponentialBackoffRunWithAbortSignal); test('ExponentialBackoff: aborts with aborted string creates error', testExponentialBackoffRunAbortedStringCreatesError); 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: subtracts jitter from the capped delay', testExponentialBackoffAppliesJitter); test('ExponentialBackoff.validateOptions: accepts valid options', testExponentialBackoffValidateOptionsAcceptsValidOptions); test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues); test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter); test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues); test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN); }; await runTests();