diff --git a/source/errors.ts b/source/errors.ts new file mode 100644 index 0000000..02b32f7 --- /dev/null +++ b/source/errors.ts @@ -0,0 +1,22 @@ +/** + * Error thrown when the maximum number of retries is hit in an exponential backoff + */ +export class ExponentialBackoffMaxRetriesHitError extends Error { + constructor(errors: Array) { + super('Exponential backoff: Max retries hit', { cause: errors }); + this.name = 'ExponentialBackoffMaxRetriesHitError'; + } +} + +/** + * Error thrown when the exponential backoff retries are stopped + */ +export class ExponentialBackoffStoppedRetriesError extends Error { + constructor(reason: unknown) { + // Convert the reason to an error if it is not an error + const reasonError = reason instanceof Error ? reason : new Error(`${reason}`); + + super(`Exponential backoff was aborted: "${reasonError.message}"`, { cause: reasonError }); + this.name = 'ExponentialBackoffStoppedRetriesError'; + } +} diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts new file mode 100644 index 0000000..8b7b8a8 --- /dev/null +++ b/source/exponential-backoff.ts @@ -0,0 +1,275 @@ +import { ExponentialBackoffStoppedRetriesError, ExponentialBackoffMaxRetriesHitError } from './errors.ts'; + +/** + * Exponential backoff is a technique used to retry a function after a delay. + * + * The delay increases exponentially with each attempt, up to a maximum delay. + * + * The jitter is a random amount of time subtracted from the delay to prevent thundering herd problems. + * + * The growth rate is the factor by which the delay increases with each attempt. + */ +export class ExponentialBackoff { + readonly #options: ExponentialBackoffOptions; + + /** + * Creates a new exponential-backoff instance. + * + * Unspecified options use the defaults listed below. + * + * @param options - Exponential-backoff configuration overrides. + * @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms. + * @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`. + * @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms. + * @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`. + * @param options.jitter - Maximum proportional reduction subtracted from each delay (0–1). Default: `0.1`. + */ + constructor(options: Partial = {}) { + this.#options = { + maxDelay: 10_000, + maxAttempts: 10, + baseDelay: 1_000, + growthRate: 2, + jitter: 0.1, + ...options, + }; + + ExponentialBackoff.validateOptions(this.#options); + } + + /** + * Create a new ExponentialBackoff instance + * + * @param config - The configuration for the exponential backoff + * @returns The ExponentialBackoff instance + */ + static from(config?: Partial): ExponentialBackoff { + const backoff = new ExponentialBackoff(config); + + return backoff; + } + + /** + * Run the function with exponential backoff + * + * @param taskFn - The function to run + * @param onError - The callback to call when an error occurs + * @param options - The configuration for the exponential backoff + * + * @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function + * @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated + * + * @returns The result of the function + */ + static run( + taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise, + onError = (_error: Error): void => {}, + options?: Partial, + ): Promise { + const backoff = ExponentialBackoff.from(options); + + return backoff.run(taskFn, onError); + } + + /** + * Calculate the delay before we should attempt to retry + * + * @param options - The configuration for the exponential backoff + * @param attempt - The current attempt number + * @returns The time in milliseconds before another attempt should be made + */ + public static calculateDelay(options: ExponentialBackoffOptions, attempt: number): number { + // Get the power of the growth rate + const power = options.growthRate ** attempt; + + // Get the delay before jitter or limit + const rawDelay = options.baseDelay * power; + + // Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay + const cappedDelay = Math.min(rawDelay, options.maxDelay); + + // Get a random number for the amount to "jitter" the delay by + const jitterAmount = Math.random(); + + // Calculate the jitter + const jitter = jitterAmount * options.jitter * cappedDelay; + + // Subtract the jitter from the delay + return cappedDelay - jitter; + } + + /** + * Validate the options for the exponential backoff + * + * @param options - The options to validate + * + * @throws An error if the options are invalid + */ + public static validateOptions(options: ExponentialBackoffOptions): void { + // Validate the max delay is a finite number not less than 0 + if (!Number.isFinite(options.maxDelay)) { + throw new Error('maxDelay must be a finite number'); + } + + if (options.maxDelay < 0) { + throw new Error('maxDelay must be not less than 0'); + } + + // Validate the max attempts is a finite number not less than 0 + if (!Number.isFinite(options.maxAttempts)) { + throw new Error('maxAttempts must be a finite number'); + } + + if (options.maxAttempts < 0) { + throw new Error('maxAttempts must be not less than 0'); + } + + // Validate the base delay is a finite number not less than 0 + if (!Number.isFinite(options.baseDelay)) { + throw new Error('baseDelay must be a finite number'); + } + + if (options.baseDelay < 0) { + throw new Error('baseDelay must be not less than 0'); + } + + // Validate the growth rate is a finite number not less than 0 + if (!Number.isFinite(options.growthRate)) { + throw new Error('growthRate must be a finite number'); + } + + if (options.growthRate < 0) { + throw new Error('growthRate must be not less than 0'); + } + + // Validate the jitter is a finite number not less than 0 or greater than 1 + if (!Number.isFinite(options.jitter)) { + throw new Error('jitter must be a finite number'); + } + + if (options.jitter < 0 || options.jitter > 1) { + throw new Error('jitter must be not less than 0 or greater than 1'); + } + } + + /** + * Run the function with exponential backoff + * + * If the function fails but we have not hit the max attempts, the error will be passed to the onError callback + * and the function will be retried with an exponential delay + * + * If the function fails and we have hit the max attempts, an ExponentialBackoffMaxRetriesHitError will be thrown with all the errors that were thrown by the task function + * + * @param taskFn - The function to run + * @param onError - The callback to call when an error occurs + * + * @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function + * @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated + * + * @returns The result of the function + */ + async run( + taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise, + onError = (_error: Error): void => {}, + ): Promise { + // Initialize an abort signal to allow the task function to be aborted + const abortController = new AbortController(); + const stopRetries = abortController.abort.bind(abortController); + + // Initialize an empty array to store the errors + const errors: Error[] = []; + + // Initialize the attempt counter + let attempt = 0; + + // If the max attempts is 0, we should continue indefinitely. + const unlimitedAttempts = this.#options.maxAttempts === 0; + + // Loop until we succeed, hit the max attempts, or the abort signal is activated + while (true) { + try { + // Await the promise before returning so its execution context remains in the try-catch + // If we didn't await, this `run` function would successfully return and any errors would not be caught here. + return await taskFn({ stopRetries }); + } catch (error) { + // Store the error in case we fail every attempt + const errorInstance = error instanceof Error ? error : new Error(`${error}`); + onError(errorInstance); + + // If we have unlimited attempts, don't append this to the errors array to prevent a memory leak. + if (!unlimitedAttempts) { + errors.push(errorInstance); + } + } + + // Calculate the count for next attempt. Do this now so we can exit before waiting and before running the next attempt. + const nextAttemptCount = attempt + 1; + const nextAttemptExceedsMaxAttempts = nextAttemptCount >= this.#options.maxAttempts; + + // If the next attempt exceeds the max attempts, break out of the loop + if (!unlimitedAttempts && nextAttemptExceedsMaxAttempts) { + break; + } + + // Check if the abort signal has been aborted + if (abortController.signal.aborted) { + // Throw an error if the abort signal has been aborted + throw new ExponentialBackoffStoppedRetriesError(abortController.signal.reason); + } + + // Wait before going to the next attempt + const delay = ExponentialBackoff.calculateDelay(this.#options, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + + attempt++; + } + + // We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got + throw new ExponentialBackoffMaxRetriesHitError(errors); + } +} + +export type ExponentialBackoffOptions = { + + /** + * The maximum delay between attempts in milliseconds + */ + maxDelay: number; + + /** + * The maximum number of attempts. Passing 0 will result in infinite attempts. + */ + maxAttempts: number; + + /** + * The base delay between attempts in milliseconds + */ + baseDelay: number; + + /** + * The growth rate of the delay + */ + growthRate: number; + + /** + * The jitter of the delay as a percentage of growthRate. The jitter is subtracted from the delay. + */ + jitter: number; +}; + +/** + * The function to call to stop the retries. + * This mimics the AbortSignal.abort function by taking in a reason for stopping + * + * @param reason - The reason for stopping the retries. + */ +export type ExponentialBackoffStopRetriesFunction = (reason: unknown) => void; + +/** + * The parameters for the task function + * + * @param stopRetries - The function to call to stop the retries + */ +export type ExponentialBackoffCallbackParameters = { + stopRetries: ExponentialBackoffStopRetriesFunction; +}; diff --git a/source/index.ts b/source/index.ts index a29e6ae..dbbc78c 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,3 +1,4 @@ +export * from './exponential-backoff.ts'; export * from './extended-json.ts'; export * from './script.ts'; export * from './sse-session/index.ts'; diff --git a/test/exponential-backoff.test.ts b/test/exponential-backoff.test.ts new file mode 100644 index 0000000..7c29a96 --- /dev/null +++ b/test/exponential-backoff.test.ts @@ -0,0 +1,595 @@ +import { expect, test, vi } from 'vitest'; +import { ExponentialBackoff } from '../source/exponential-backoff.ts'; +import { ExponentialBackoffMaxRetriesHitError, ExponentialBackoffStoppedRetriesError } from '../source/errors.ts'; + +/** + * 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(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 (): 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 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 (): 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 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 (): 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 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 ExponentialBackoffStoppedRetriesError 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(({ 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 + 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 ExponentialBackoffStoppedRetriesError 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(({ 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 + 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(`${field} must be not 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('jitter must be not less than 0 or greater than 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(`${field} must be a finite number`); + } +}; + +/** + * 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(`${field} must be a finite number`); + } +}; + +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();