diff --git a/package-lock.json b/package-lock.json index 1be29c6..3c4740a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@xo-cash/utils", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@xo-cash/utils", - "version": "0.0.3", + "version": "0.0.4", "license": "MIT", "dependencies": { "@bitauth/libauth": "^3.1.0-next.8", @@ -8197,9 +8197,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -8217,7 +8217,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index 7068385..73ebff8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xo-cash/utils", - "version": "0.0.3", + "version": "0.0.4", "description": "XO Cash utilities", "type": "module", "types": "./dist/index.d.mts", diff --git a/source/exponential-backoff/errors.ts b/source/exponential-backoff/errors.ts new file mode 100644 index 0000000..749f835 --- /dev/null +++ b/source/exponential-backoff/errors.ts @@ -0,0 +1,92 @@ +/* eslint-disable max-classes-per-file */ + +/** + * 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'; + } +} + +/** + * Error thrown when an exponential backoff option is too small + */ +export class ExponentialBackoffNumberTooSmallError extends Error { + constructor(option: string, value: number, min: number) { + super(`Exponential backoff option "${option}" is too small. Must be at least ${min}. Received value: ${value}`); + this.name = 'ExponentialBackoffNumberTooSmallError'; + } +} + +/** + * Error thrown when an exponential backoff option is out of bounds + */ +export class ExponentialBackoffNumberOutOfBoundsError extends Error { + constructor(option: string, value: number, min: number, max: number) { + super(`Exponential backoff option "${option}" is out of bounds. Must be between ${min} and ${max}. Received value: ${value}`); + this.name = 'ExponentialBackoffNumberOutOfBoundsError'; + } +} + +/** + * Error thrown when an exponential backoff option is an invalid infinite integer + */ +export class ExponentialBackoffNumberNotFiniteError extends Error { + constructor(option: string, value: number) { + super(`Exponential backoff option "${option}" is invalid. Must be a finite number. Received value: ${value}`); + this.name = 'ExponentialBackoffNumberNotFiniteError'; + } +} + +/** + * Error thrown when an exponential backoff option is not an integer + */ +export class ExponentialBackoffNonIntegerError extends Error { + constructor(option: string, value: number) { + super(`Exponential backoff option "${option}" is invalid. Must be an integer. Received value: ${value}`); + this.name = 'ExponentialBackoffNonIntegerError'; + } +} + +/** + * Error thrown when an externally aborted exponential backoff is aborted + * due to the external abort signal that was passed in to the constructor being aborted by an upstream consumer + */ +export class ExternallyAbortedExponentialBackoffExternalSignalAbortedError 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(`Externally aborted exponential backoff: "${reasonError.message}"`, { cause: reasonError }); + this.name = 'ExternallyAbortedExponentialBackoffExternalSignalAbortedError'; + } +} + +/** + * Error thrown when an externally aborted exponential backoff is aborted + * due to the internal abort signal being aborted using the .abort() method + */ +export class ExternallyAbortedExponentialBackoffInternalSignalAbortedError 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(`Externally aborted exponential backoff: "${reasonError.message}"`, { cause: reasonError }); + this.name = 'ExternallyAbortedExponentialBackoffInternalSignalAbortedError'; + } +} diff --git a/source/exponential-backoff/exponential-backoff-externally-aborted.ts b/source/exponential-backoff/exponential-backoff-externally-aborted.ts new file mode 100644 index 0000000..5c324ac --- /dev/null +++ b/source/exponential-backoff/exponential-backoff-externally-aborted.ts @@ -0,0 +1,96 @@ +import { + ExternallyAbortedExponentialBackoffExternalSignalAbortedError, + ExternallyAbortedExponentialBackoffInternalSignalAbortedError, +} from './errors.ts'; +import { ExponentialBackoff } from './exponential-backoff.ts'; +import type { ExponentialBackoffCallbackParameters, ExponentialBackoffOptions, ExponentialBackoffRunOptions } from './exponential-backoff.ts'; + +/** + * Options for the ExponentialBackoffExternallyAbortable class + * + * @extends Partial + * @property abortSignal - The abort signal to use for this instance + */ +export type ExponentialBackoffExternallyAbortableOptions = Partial & { + abortSignal: AbortSignal; +}; + +/** + * An exponential backoff that can be stopped by calling `.abort()` or by passing an + * `abortSignal` to the constructor. + * + * @remarks One instance can run many tasks. Aborting it stops retries for every run. + */ +export class ExponentialBackoffExternallyAbortable { + readonly #abortController = new AbortController(); + readonly #exponentialBackoff: ExponentialBackoff; + + constructor(options: Partial = {}) { + const { abortSignal, ...backoffOptions } = options; + + this.#exponentialBackoff = new ExponentialBackoff(backoffOptions); + + // Listen for the provided abort signal to be aborted + abortSignal?.addEventListener( + 'abort', + () => { + this.#abortController.abort(new ExternallyAbortedExponentialBackoffExternalSignalAbortedError(abortSignal.reason)); + }, + { + once: true, + // Clean up this listener if the internal abort controller is aborted + signal: this.#abortController.signal, + }, + ); + + // If the abort signal is already aborted, immediately abort the internal abort controller + if (abortSignal?.aborted) { + this.#abortController.abort(new ExternallyAbortedExponentialBackoffExternalSignalAbortedError(abortSignal.reason)); + } + } + + /** + * 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 every attempt fails, an ExponentialBackoffMaxRetriesHitError will be thrown + * with all errors from the task function. + * + * @param taskFn - The function to run + * @param options - Options that control this run + * + * @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 + * @throws An {@link ExternallyAbortedExponentialBackoffExternalSignalAbortedError} + * if the abort signal that was provided during construction is activated + * @throws An {@link ExternallyAbortedExponentialBackoffInternalSignalAbortedError} + * if {@link ExponentialBackoffExternallyAbortable.abort} is called on this class + * + * @returns The result of the function + */ + public run( + taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise, + options: Partial = {}, + ): Promise { + let signal = this.#abortController.signal; + + // If a signal was provided for this run, compose it with the instance-wide signal + if (options.signal !== undefined) { + signal = AbortSignal.any([ signal, options.signal ]); + } + + // Call the exponential backoff run method with the composed signal + return this.#exponentialBackoff.run(taskFn, { ...options, signal }); + } + + /** + * Stops retries for all current and future runs. + * + * @param reason - The reason for stopping retries + */ + public abort(reason: unknown): void { + this.#abortController.abort(new ExternallyAbortedExponentialBackoffInternalSignalAbortedError(reason)); + } +} diff --git a/source/exponential-backoff/exponential-backoff.ts b/source/exponential-backoff/exponential-backoff.ts new file mode 100644 index 0000000..1b46857 --- /dev/null +++ b/source/exponential-backoff/exponential-backoff.ts @@ -0,0 +1,364 @@ +import { + ExponentialBackoffStoppedRetriesError, + ExponentialBackoffMaxRetriesHitError, + ExponentialBackoffNonIntegerError, + ExponentialBackoffNumberTooSmallError, + ExponentialBackoffNumberOutOfBoundsError, + ExponentialBackoffNumberNotFiniteError, +} from './errors.ts'; +import { isWithinBounds } from '../misc.ts'; + +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; +}; + +/** + * Options that control a single exponential-backoff run. + */ +export type ExponentialBackoffRunOptions = { + + /** + * Called after each failed task attempt. + */ + onError?: ((error: Error) => void) | undefined; + + /** + * Stops pending delays and prevents future attempts when aborted. + */ + signal?: AbortSignal | undefined; +}; + +/** + * Options accepted by the static exponential-backoff run helper. + */ +export type ExponentialBackoffStaticRunOptions = Partial & ExponentialBackoffRunOptions; + +/** + * 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`. + * + * @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number + * @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds + * @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small + * @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer + */ + 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 + * + * @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number + * @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds + * @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small + * @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer + * + * @returns The ExponentialBackoff instance + */ + public 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 options - Backoff configuration and options for this run + * + * @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 + */ + public static run( + taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise, + options: Partial = {}, + ): Promise { + // Grab onError and signal from the options + const { onError, signal, ...backoffOptions } = options; + const backoff = ExponentialBackoff.from(backoffOptions); + + return backoff.run(taskFn, { onError, signal }); + } + + /** + * Validate the options for the exponential backoff + * + * @param options - The options to validate + * + * @throws {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number + * @throws {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer + * @throws {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds + * @throws {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small + */ + public static validateOptions(options: ExponentialBackoffOptions): void { + /** Validate the value is finite, throwing an {@link ExponentialBackoffInvalidInfiniteIntegerError} if the value is infinite */ + const assertIsFinite = (key: string, value: number): void => { + if (!Number.isFinite(value)) { + throw new ExponentialBackoffNumberNotFiniteError(key, value); + } + }; + + /** Validate the value is an integer, throwing a {@link ExponentialBackoffNonIntegerError} if it is not an integer */ + const assertIsInteger = (key: string, value: number): void => { + if (!Number.isInteger(value)) { + throw new ExponentialBackoffNonIntegerError(key, value); + } + }; + + /** Validate the value is greater than the minimum, throwing a {@link ExponentialBackoffNumberTooSmallError} if it is not */ + const assertIsHigherThan = (key: string, value: number, min: number): void => { + if (value < min) { + throw new ExponentialBackoffNumberTooSmallError(key, value, min); + } + }; + + /** Validate the value is within the bounds, throwing a {@link ExponentialBackoffNumberOutOfBoundsError} if it is not within the bounds */ + const assertIsWithinBounds = (key: string, value: number, min: number, max: number): void => { + if (!isWithinBounds(value, min, max)) { + throw new ExponentialBackoffNumberOutOfBoundsError(key, value, min, max); + } + }; + + // Validate the max delay + assertIsFinite('maxDelay', options.maxDelay); + assertIsHigherThan('maxDelay', options.maxDelay, 0); + + // Validate the max attempts + assertIsFinite('maxAttempts', options.maxAttempts); + assertIsInteger('maxAttempts', options.maxAttempts); + assertIsHigherThan('maxAttempts', options.maxAttempts, 0); + + // Validate the base delay + assertIsFinite('baseDelay', options.baseDelay); + assertIsHigherThan('baseDelay', options.baseDelay, 0); + + // Validate the growth rate + assertIsFinite('growthRate', options.growthRate); + assertIsHigherThan('growthRate', options.growthRate, 0); + + // Validate the jitter + assertIsFinite('jitter', options.jitter); + assertIsWithinBounds('jitter', options.jitter, 0, 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 every attempt fails, an ExponentialBackoffMaxRetriesHitError will be thrown + * with all errors from the task function. + * + * @param taskFn - The function to run + * @param options - Options that control this run + * + * @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 + */ + public async run( + taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise, + options: ExponentialBackoffRunOptions = {}, + ): Promise { + const { onError, signal: suppliedSignal } = options; + + // The task can stop its own retries without owning the signal supplied by the caller. + const abortController = new AbortController(); + const stopRetries = abortController.abort.bind(abortController); + + // Start with the abort controller signal + const signals = [ abortController.signal ]; + + // If we received a signal from the caller, add it to the signals array + if (suppliedSignal !== undefined) { + signals.push(suppliedSignal); + } + + // Compose the signals together + const signal = AbortSignal.any(signals); + + // If the composed signal is already aborted, throw an error + if (signal.aborted) { + throw new ExponentialBackoffStoppedRetriesError(signal.reason); + } + + // 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); + } + } + + // Check if the abort signal has been activated + if (signal.aborted) { + // Throw an error if the abort signal has been activated + throw new ExponentialBackoffStoppedRetriesError(signal.reason); + } + + // 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; + } + + // Wait before going to the next attempt + const delay = this.#calculateDelay(this.#options, attempt); + + // Wait for the delay or the abort signal + await new Promise((resolve, reject) => { + // eslint-disable-next-line prefer-const + let timeout: ReturnType; + + // Handle the abort signal + const abortHandler = (): void => { + clearTimeout(timeout); + reject(new ExponentialBackoffStoppedRetriesError(signal.reason)); + }; + + // Handle the timeout + const timeoutHandler = (): void => { + signal.removeEventListener('abort', abortHandler); + resolve(undefined); + }; + + // Set the timeout + timeout = setTimeout(timeoutHandler, delay); + + // Add the abort handler to the abort signal + signal.addEventListener('abort', abortHandler, { once: true }); + }); + + attempt++; + } + + // We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got + throw new ExponentialBackoffMaxRetriesHitError(errors); + } + + /** + * 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 + */ + #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; + } +} diff --git a/source/exponential-backoff/index.ts b/source/exponential-backoff/index.ts new file mode 100644 index 0000000..9240a6f --- /dev/null +++ b/source/exponential-backoff/index.ts @@ -0,0 +1,3 @@ +export * from './errors.ts'; +export * from './exponential-backoff.ts'; +export * from './exponential-backoff-externally-aborted.ts'; diff --git a/source/index.ts b/source/index.ts index 8cc874b..c64cbf9 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,4 +1,6 @@ +export * from './errors.ts'; export * from './event-emitter.ts'; +export * from './exponential-backoff/index.ts'; export * from './extended-json.ts'; export * from './script.ts'; export * from './sse-session/index.ts'; diff --git a/source/misc.ts b/source/misc.ts index 87aff34..edd5854 100644 --- a/source/misc.ts +++ b/source/misc.ts @@ -20,3 +20,20 @@ export const deepFreeze = (value: T): DeeplyReadonly => { return value; }; + +/** + * Validate the value is within the bounds, returning true if it is within the bounds, false otherwise + * + * @param value - The value to validate + * @param min - The minimum value + * @param max - The maximum value + * + * @returns True if the value is within the bounds, false otherwise + */ +export const isWithinBounds = (value: number, min: number, max: number): boolean => { + if (value < min || value > max) { + return false; + } + + return true; +}; diff --git a/test/exponential-backoff/exponential-backoff-externally-aborted.test.ts b/test/exponential-backoff/exponential-backoff-externally-aborted.test.ts new file mode 100644 index 0000000..7ef0921 --- /dev/null +++ b/test/exponential-backoff/exponential-backoff-externally-aborted.test.ts @@ -0,0 +1,307 @@ +import { expect, test, vi } from 'vitest'; +import { ExponentialBackoffExternallyAbortable } from '../../source/exponential-backoff/exponential-backoff-externally-aborted.ts'; +import type { ExponentialBackoffExternallyAbortableOptions } from '../../source/exponential-backoff/exponential-backoff-externally-aborted.ts'; +import { + ExponentialBackoffNumberTooSmallError, + ExponentialBackoffStoppedRetriesError, + ExternallyAbortedExponentialBackoffExternalSignalAbortedError, + ExternallyAbortedExponentialBackoffInternalSignalAbortedError, +} from '../../source/exponential-backoff/errors.ts'; +import { testExponentialBackoff } from './exponential-backoff-run.shared.ts'; + +/** + * Tests that invalid constructor options are still rejected by the inner ExponentialBackoff. + */ +const testExternallyAbortedExponentialBackoffConstructorValidatesOptions = (): void => { + expect(() => new ExponentialBackoffExternallyAbortable({ maxAttempts: -1 })).toThrow(ExponentialBackoffNumberTooSmallError); +}; + +/** + * Tests that {@link ExponentialBackoffExternallyAbortable.abort} before `run` prevents the task from + * ever being invoked and rejects with a stopped-retries error whose cause is the internal abort error. + */ +const testExternallyAbortedExponentialBackoffAbortBeforeRun = async (): Promise => { + const taskFn = vi.fn(async () => 'should not run'); + const onError = vi.fn(); + const abortReason = 'stopped by abort()'; + + const backoff = new ExponentialBackoffExternallyAbortable({ + baseDelay: 0, + jitter: 0, + maxAttempts: 3, + }); + + // Abort before any attempt so the wrapper should short-circuit on the first inner call. + backoff.abort(abortReason); + + try { + await backoff.run(taskFn, { onError }); + expect.fail('Expected ExponentialBackoffStoppedRetriesError to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError); + expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffInternalSignalAbortedError); + expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toEqual(new Error(abortReason)); + } + + // The user task must not run; the wrapper throws before forwarding to it. + expect(taskFn).not.toHaveBeenCalled(); + + expect(onError).not.toHaveBeenCalled(); +}; + +/** + * Tests that {@link ExponentialBackoffExternallyAbortable.abort} during a retry delay takes effect + * immediately without calling the task again. + */ +const testExternallyAbortedExponentialBackoffAbortBetweenAttempts = async (): Promise => { + vi.useFakeTimers(); + + try { + const taskFn = vi.fn(async () => { + throw new Error('retry me'); + }); + const onError = vi.fn(); + const abortReason = new Error('stopped between attempts'); + + const backoff = new ExponentialBackoffExternallyAbortable({ + baseDelay: 1_000, + jitter: 0, + maxAttempts: 0, + }); + + const errorPromise = backoff.run(taskFn, { onError }).catch((error: unknown) => error); + + // First attempt runs immediately and fails, then a 1000ms delay is scheduled. + await Promise.resolve(); + expect(taskFn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(500); + backoff.abort(abortReason); + + const error = await errorPromise; + expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError); + expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffInternalSignalAbortedError); + expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toBe(abortReason); + + // The user task ran only on the first attempt; the aborted second attempt never forwarded to it. + expect(taskFn).toHaveBeenCalledTimes(1); + + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0].message).toBe('retry me'); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}; + +/** + * Tests that a constructor `abortSignal` that is already aborted prevents the task from running. + */ +const testExternallyAbortedExponentialBackoffAbortSignalAlreadyAborted = async (): Promise => { + const taskFn = vi.fn(async () => 'should not run'); + const onError = vi.fn(); + const abortReason = 'signal already aborted'; + + const abortController = new AbortController(); + abortController.abort(abortReason); + + const backoff = new ExponentialBackoffExternallyAbortable({ + abortSignal: abortController.signal, + baseDelay: 0, + jitter: 0, + maxAttempts: 3, + }); + + try { + await backoff.run(taskFn, { onError }); + expect.fail('Expected ExponentialBackoffStoppedRetriesError to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError); + expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffExternalSignalAbortedError); + expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toEqual(new Error(abortReason)); + } + + expect(taskFn).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); +}; + +/** + * Tests that aborting the constructor `abortSignal` interrupts the current retry delay. + */ +const testExternallyAbortedExponentialBackoffAbortSignalBetweenAttempts = async (): Promise => { + vi.useFakeTimers(); + + try { + const taskFn = vi.fn(async () => { + throw new Error('retry me'); + }); + const onError = vi.fn(); + const abortReason = new Error('signal aborted between attempts'); + + const abortController = new AbortController(); + const backoff = new ExponentialBackoffExternallyAbortable({ + abortSignal: abortController.signal, + baseDelay: 1_000, + jitter: 0, + maxAttempts: 0, + }); + + const errorPromise = backoff.run(taskFn, { onError }).catch((error: unknown) => error); + + await Promise.resolve(); + expect(taskFn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(500); + abortController.abort(abortReason); + + const error = await errorPromise; + expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError); + expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffExternalSignalAbortedError); + expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toBe(abortReason); + + expect(taskFn).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0].message).toBe('retry me'); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}; + +/** + * Tests that a single {@link ExponentialBackoffExternallyAbortable.abort} stops every concurrent `run` on that instance. + */ +const testExternallyAbortedExponentialBackoffAbortStopsConcurrentRuns = async (): Promise => { + vi.useFakeTimers(); + + try { + const firstTaskFn = vi.fn(async () => { + throw new Error('first run failed'); + }); + const secondTaskFn = vi.fn(async () => { + throw new Error('second run failed'); + }); + const abortReason = new Error('stop all runs'); + + // One instance is shared across concurrent runs, matching the class remark that abort stops all of them. + const backoff = new ExponentialBackoffExternallyAbortable({ + baseDelay: 1_000, + jitter: 0, + maxAttempts: 0, + }); + + // Handle both rejections before aborting the shared instance. + const firstErrorPromise = backoff.run(firstTaskFn).catch((error: unknown) => error); + const secondErrorPromise = backoff.run(secondTaskFn).catch((error: unknown) => error); + + await Promise.resolve(); + expect(firstTaskFn).toHaveBeenCalledTimes(1); + expect(secondTaskFn).toHaveBeenCalledTimes(1); + + backoff.abort(abortReason); + + await expect(firstErrorPromise).resolves.toBeInstanceOf(ExponentialBackoffStoppedRetriesError); + await expect(secondErrorPromise).resolves.toBeInstanceOf(ExponentialBackoffStoppedRetriesError); + + expect(firstTaskFn).toHaveBeenCalledTimes(1); + expect(secondTaskFn).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}; + +/** + * Tests that a signal supplied for one run composes with the instance-wide abort sources. + */ +const testExternallyAbortedExponentialBackoffRunSignalInterruptsDelay = async (): Promise => { + vi.useFakeTimers(); + + try { + const abortReason = new Error('stop this run'); + const abortController = new AbortController(); + const taskFn = vi.fn().mockRejectedValue(new Error('retry me')); + const backoff = new ExponentialBackoffExternallyAbortable({ + baseDelay: 1_000, + jitter: 0, + maxAttempts: 0, + }); + + const errorPromise = backoff.run(taskFn, { signal: abortController.signal }).catch((error: unknown) => error); + + await Promise.resolve(); + expect(taskFn).toHaveBeenCalledOnce(); + + abortController.abort(abortReason); + + const error = await errorPromise; + expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError); + expect((error as ExponentialBackoffStoppedRetriesError).cause).toBe(abortReason); + expect(taskFn).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}; + +/** + * Tests that a successful run is unaffected by a later abort of the shared instance. + */ +const testExternallyAbortedExponentialBackoffSuccessfulRunIgnoresLaterAbort = async (): Promise => { + const taskFn = vi.fn(async () => 'success'); + + const backoff = new ExponentialBackoffExternallyAbortable({ + baseDelay: 0, + jitter: 0, + }); + + const result = await backoff.run(taskFn); + + expect(result).toBe('success'); + expect(taskFn).toHaveBeenCalledOnce(); + + // Aborting after success must not throw retroactively; it only affects subsequent runs. + backoff.abort(new Error('too late')); +}; + +const runTests = async (): Promise => { + const factory = (options?: Partial): ExponentialBackoffExternallyAbortable => { + return new ExponentialBackoffExternallyAbortable(options); + }; + + // Shared retry/delay/stopRetries contract, run against an adapter around instance run. + testExponentialBackoff('ExternallyAbortedExponentialBackoff', factory); + + // Behavior that only exists on the externally-aborted wrapper. + test( + 'ExternallyAbortedExponentialBackoff: constructor still validates options', + testExternallyAbortedExponentialBackoffConstructorValidatesOptions, + ); + test( + 'ExternallyAbortedExponentialBackoff: abort before run stops without calling the task', + testExternallyAbortedExponentialBackoffAbortBeforeRun, + ); + test( + 'ExternallyAbortedExponentialBackoff: abort between attempts interrupts the delay', + testExternallyAbortedExponentialBackoffAbortBetweenAttempts, + ); + test( + 'ExternallyAbortedExponentialBackoff: abortSignal already aborted stops without calling the task', + testExternallyAbortedExponentialBackoffAbortSignalAlreadyAborted, + ); + test( + 'ExternallyAbortedExponentialBackoff: abortSignal between attempts interrupts the delay', + testExternallyAbortedExponentialBackoffAbortSignalBetweenAttempts, + ); + test('ExternallyAbortedExponentialBackoff: abort stops every concurrent run', testExternallyAbortedExponentialBackoffAbortStopsConcurrentRuns); + test( + 'ExternallyAbortedExponentialBackoff: a per-run signal interrupts the delay', + testExternallyAbortedExponentialBackoffRunSignalInterruptsDelay, + ); + test( + 'ExternallyAbortedExponentialBackoff: successful run is unaffected by a later abort', + testExternallyAbortedExponentialBackoffSuccessfulRunIgnoresLaterAbort, + ); +}; + +await runTests(); diff --git a/test/exponential-backoff/exponential-backoff-run.shared.ts b/test/exponential-backoff/exponential-backoff-run.shared.ts new file mode 100644 index 0000000..df06d69 --- /dev/null +++ b/test/exponential-backoff/exponential-backoff-run.shared.ts @@ -0,0 +1,516 @@ +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)); + }); +}; diff --git a/test/exponential-backoff/exponential-backoff.test.ts b/test/exponential-backoff/exponential-backoff.test.ts new file mode 100644 index 0000000..ef29d65 --- /dev/null +++ b/test/exponential-backoff/exponential-backoff.test.ts @@ -0,0 +1,259 @@ +import { expect, test, vi } from 'vitest'; + +import { testExponentialBackoff } from './exponential-backoff-run.shared.ts'; + +import { ExponentialBackoff } from '../../source/exponential-backoff/exponential-backoff.ts'; +import type { ExponentialBackoffOptions } from '../../source/exponential-backoff/exponential-backoff.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 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 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 omitted to verify the default no-op handler is used. + const result = await ExponentialBackoff.run(rejectThenResolveFn, { + 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 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(`Exponential backoff option "${field}" is too small. Must be at least 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('Exponential backoff option "jitter" is out of bounds. Must be 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(`Exponential backoff option "${field}" is invalid. Must be a finite number`); + } +}; + +/** + * Tests that {@link ExponentialBackoff.validateOptions} rejects non-integer values. + */ +const testExponentialBackoffValidateOptionsRejectsNonIntegerValues = (): void => { + // Define our test cases with each value being a non-integer + const nonIntegerCases = [{ field: 'maxAttempts', value: 1.5 }] as const; + + // Iterate through the test cases and expect an error to be thrown + for (const { field, value } of nonIntegerCases) { + expect(() => + ExponentialBackoff.validateOptions({ + ...validExponentialBackoffOptions, + [field]: value, + })).toThrow(`Exponential backoff option "${field}" is invalid. Must be an integer`); + } +}; + +/** + * 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(`Exponential backoff option "${field}" is invalid. Must be a finite number`); + } +}; + +const runTests = async (): Promise => { + const exponentialBackoffFactory = (options?: Partial): ExponentialBackoff => { + return new ExponentialBackoff(options); + }; + + // Test the shared ExponentialBackoff contract. + testExponentialBackoff('ExponentialBackoff', exponentialBackoffFactory); + + // APIs that only exist on ExponentialBackoff itself. + test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun); + test('ExponentialBackoff: uses the default options when no options are passed', testExponentialBackoffRunUsesDefaultOptions); + test('ExponentialBackoff: accepts partial options and merges them with defaults', testExponentialBackoffRunWithPartialOptions); + test('ExponentialBackoff: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError); + 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 non-integer values', testExponentialBackoffValidateOptionsRejectsNonIntegerValues); + test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN); +}; + +await runTests(); diff --git a/tsconfig.json b/tsconfig.json index 106b15f..e6b7c1a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "allowImportingTsExtensions": true, + "exactOptionalPropertyTypes": true, "noEmit": true, "declaration": true, "declarationMap": true