From 6fe4a64562da1269543823327d629055527ccf86 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 29 Jun 2026 07:18:20 +0000 Subject: [PATCH 01/16] Add exponential backoff utility --- source/errors.ts | 9 + source/exponential-backoff.ts | 150 +++++++++++++ source/index.ts | 1 + test/exponential-backoff.test.ts | 352 +++++++++++++++++++++++++++++++ 4 files changed, 512 insertions(+) create mode 100644 source/errors.ts create mode 100644 source/exponential-backoff.ts create mode 100644 test/exponential-backoff.test.ts diff --git a/source/errors.ts b/source/errors.ts new file mode 100644 index 0000000..37f1113 --- /dev/null +++ b/source/errors.ts @@ -0,0 +1,9 @@ +/** + * Error thrown when the maximum number of retries is hit in an exponential backoff + */ +export class ExponentialBackoffMaxRetriesHitError extends Error { + constructor() { + super('Exponential backoff: Max retries hit'); + this.name = 'ExponentialBackoffMaxRetriesHitError'; + } +} diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts new file mode 100644 index 0000000..06b6b07 --- /dev/null +++ b/source/exponential-backoff.ts @@ -0,0 +1,150 @@ +import { 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 added to the delay to prevent thundering herd problems. + * + * The growth rate is the factor by which the delay increases with each attempt. + */ +export class ExponentialBackoff { + /** + * 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 fn - The function to run + * @param onError - The callback to call when an error occurs + * @param options - The configuration for the exponential backoff + * + * @throws The last error if the function fails and we have hit the max attempts + * + * @returns The result of the function + */ + static run(taskFn: () => Promise, onError = (_error: Error): void => {}, options?: Partial): Promise { + const backoff = ExponentialBackoff.from(options); + + return backoff.run(taskFn, onError); + } + + private readonly options: ExponentialBackoffOptions; + + constructor(options?: Partial) { + this.options = { + maxDelay: 10000, + maxAttempts: 10, + baseDelay: 1000, + growthRate: 2, + jitter: 0.1, + ...options, + }; + } + + /** + * 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, the last error will be thrown + * + * @param fn - The function to run + * @param onError - The callback to call when an error occurs + * + * @throws The last error if the function fails and we have hit the max attempts + * + * @returns The result of the function + */ + async run(taskFn: () => Promise, onError = (_error: Error): void => {}): Promise { + let lastError: Error = new ExponentialBackoffMaxRetriesHitError(); + + let attempt = 0; + + while (attempt < this.options.maxAttempts || this.options.maxAttempts == 0) { + try { + return await taskFn(); + } catch (error) { + // Store the error in case we fail every attempt + lastError = error instanceof Error ? error : new Error(`${error}`); + onError(lastError); + + // 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 the last error we got + throw lastError; + } + + /** + * Calculate the delay before we should attempt to retry + * + * NOTE: The maximum delay is (maxDelay * (1 + jitter)) + * + * @param attempt + * @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 the jitter direction. This will be between -1 and 1 + const jitterDirection = 2 * Math.random() - 1; + + // Calculate the jitter + const jitter = jitterDirection * options.jitter * cappedDelay; + + // Add the jitter to the delay + return cappedDelay + jitter; + } +} + +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 + */ + jitter: number; +}; diff --git a/source/index.ts b/source/index.ts index 5c97c08..899b941 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 './template/errors.ts'; diff --git a/test/exponential-backoff.test.ts b/test/exponential-backoff.test.ts new file mode 100644 index 0000000..ed9acca --- /dev/null +++ b/test/exponential-backoff.test.ts @@ -0,0 +1,352 @@ +import { expect, test, vi } from 'vitest'; +import { ExponentialBackoff } from '../source/exponential-backoff.ts'; + +/** + * Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance + * with library defaults (including the default 1000ms base delay) when no options are passed. + */ +const testExponentialBackoffRunUsesDefaultOptions = async (): Promise => { + // Fake timers let us advance time without waiting real seconds between retries. + vi.useFakeTimers(); + // Pin Math.random so jitter does not randomize the delay we are about to measure. + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + try { + // The wrapped function fails on its first invocation and succeeds on the second. + // That forces ExponentialBackoff.run down the retry path using default options. + const fn = vi.fn().mockRejectedValueOnce(new Error('retry me')) +.mockResolvedValueOnce('static-result'); + + // Call the static helper with no onError and no options — defaults apply entirely. + const promise = ExponentialBackoff.run(fn); + + // Yield one microtask so the first (immediate) attempt completes and schedules the retry timer. + await Promise.resolve(); + expect(fn).toHaveBeenCalledTimes(1); + + // Default baseDelay is 1000ms; advancing less would not trigger the retry yet. + await vi.advanceTimersByTimeAsync(1_000); + + // The retry should have succeeded and returned the resolved value from the mock. + await expect(promise).resolves.toBe('static-result'); + expect(fn).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that {@link ExponentialBackoff.run} accepts a partial options object and merges it + * with defaults, still retrying when only some fields are overridden. + */ +const testExponentialBackoffRunWithPartialOptions = async (): Promise => { + // Same fail-then-succeed pattern; we only care that partial options still enable a retry. + const fn = vi.fn().mockRejectedValueOnce(new Error('retry me')) +.mockResolvedValueOnce('done'); + + // baseDelay/jitter of 0 skip real waiting; maxAttempts: 3 gives headroom for one retry. + // onError is explicitly undefined to verify the default no-op handler is used. + const result = await ExponentialBackoff.run(fn, undefined, { + baseDelay: 0, + jitter: 0, + maxAttempts: 3, + }); + + expect(result).toBe('done'); + expect(fn).toHaveBeenCalledTimes(2); +}; + +/** + * Tests that calling {@link ExponentialBackoff.run} on a constructed instance applies + * the instance's stored options when no per-run options are supplied. + */ +const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise => { + const fn = vi.fn().mockRejectedValueOnce(new Error('retry me')) +.mockResolvedValueOnce('instance-result'); + + // Options live on the instance; run(fn) should read them instead of static defaults. + const backoff = new ExponentialBackoff({ baseDelay: 0, jitter: 0, maxAttempts: 3 }); + + const result = await backoff.run(fn); + + expect(result).toBe('instance-result'); + expect(fn).toHaveBeenCalledTimes(2); +}; + +/** + * Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs. + */ +const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise => { + // Always resolves — never enters the catch/retry branch. + const fn = vi.fn(async () => 'success'); + const onError = vi.fn(); + + const result = await ExponentialBackoff.run(fn, onError, { + baseDelay: 0, + jitter: 0, + }); + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledOnce(); + expect(onError).not.toHaveBeenCalled(); +}; + +/** + * Tests that retries continue across multiple failures until the function eventually resolves. + */ +const testExponentialBackoffRetriesUntilSuccess = async (): Promise => { + // Three invocations: two rejections then a success on the third call. + const fn = vi.fn().mockRejectedValueOnce(new Error('attempt 1')) +.mockRejectedValueOnce(new Error('attempt 2')) +.mockResolvedValueOnce('success'); + + // maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap. + const result = await ExponentialBackoff.run(fn, () => {}, { + baseDelay: 0, + jitter: 0, + maxAttempts: 5, + }); + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(3); +}; + +/** + * Tests that the onError callback is invoked once for every failed attempt, including the last one + * before the final rejection is thrown to the caller. + */ +const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise => { + const error = new Error('temporary failure'); + + // Always rejects with the same error — we will exhaust all attempts. + const fn = vi.fn().mockRejectedValue(error); + const onError = vi.fn(); + + // maxAttempts: 3 means three tries total, all of which will fail. + await expect(ExponentialBackoff.run(fn, onError, { + baseDelay: 0, + jitter: 0, + maxAttempts: 3, + })).rejects.toThrow('temporary failure'); + + expect(onError).toHaveBeenCalledTimes(3); + expect(onError).toHaveBeenCalledWith(error); +}; + +/** + * Tests that when all attempts are exhausted the caller receives the error from the final attempt, + * not an earlier one. + */ +const testExponentialBackoffThrowsLastErrorWhenExhausted = async (): Promise => { + const firstError = new Error('first'); + const lastError = new Error('last'); + + // Two distinct errors so we can prove the last one surfaces. + const fn = vi.fn().mockRejectedValueOnce(firstError) +.mockRejectedValueOnce(lastError); + + await expect(ExponentialBackoff.run(fn, () => {}, { + baseDelay: 0, + jitter: 0, + maxAttempts: 2, + })).rejects.toThrow('last'); + + expect(fn).toHaveBeenCalledTimes(2); +}; + +/** + * Tests that rejections which are not Error instances are coerced to Error before onError runs, + * so callers always observe a consistent error type in the callback. + */ +const testExponentialBackoffWrapsNonErrorThrows = async (): Promise => { + // Reject with a plain string — not an Error subclass. + const fn = vi.fn().mockRejectedValue('not-an-error'); + const onError = vi.fn(); + + // Single attempt — we fail fast and inspect what onError received. + await expect(ExponentialBackoff.run(fn, onError, { + baseDelay: 0, + jitter: 0, + maxAttempts: 1, + })).rejects.toThrow('not-an-error'); + + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + expect(onError.mock.calls[0][0].message).toBe('not-an-error'); +}; + +/** + * Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run} + * as an alternative to the static helper. + */ +const testExponentialBackoffFromAndInstanceRun = async (): Promise => { + const fn = vi.fn(async () => 42); + + // from() is a convenience constructor; run() on the result should behave like the static path. + const backoff = ExponentialBackoff.from({ + baseDelay: 0, + jitter: 0, + }); + + const result = await backoff.run(fn); + + expect(result).toBe(42); + expect(fn).toHaveBeenCalledOnce(); +}; + +/** + * Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds. + */ +const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (): Promise => { + // Four invocations: three failures then success — would exceed a cap of 3 if one existed. + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('attempt 1')) + .mockRejectedValueOnce(new Error('attempt 2')) + .mockRejectedValueOnce(new Error('attempt 3')) + .mockResolvedValueOnce('eventually'); + + const result = await ExponentialBackoff.run(fn, () => {}, { + baseDelay: 0, + jitter: 0, + maxAttempts: 0, + }); + + expect(result).toBe('eventually'); + expect(fn).toHaveBeenCalledTimes(4); +}; + +/** + * Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds + * (with jitter disabled so the math is exact). + */ +const testExponentialBackoffIncreasesDelayExponentially = async (): Promise => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + try { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('attempt 1')) + .mockRejectedValueOnce(new Error('attempt 2')) + .mockResolvedValueOnce('success'); + + const promise = ExponentialBackoff.run(fn, () => {}, { + baseDelay: 100, + growthRate: 2, + jitter: 0, + maxDelay: 10_000, + maxAttempts: 5, + }); + + // Attempt 0 fires synchronously on the first microtask tick. + await Promise.resolve(); + expect(fn).toHaveBeenCalledTimes(1); + + // After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1. + await vi.advanceTimersByTimeAsync(100); + expect(fn).toHaveBeenCalledTimes(2); + + // After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2. + await vi.advanceTimersByTimeAsync(200); + expect(fn).toHaveBeenCalledTimes(3); + + await expect(promise).resolves.toBe('success'); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that computed delay never exceeds maxDelay even when exponential growth would go higher. + */ +const testExponentialBackoffCapsDelayAtMaxDelay = async (): Promise => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + try { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('attempt 1')) + .mockRejectedValueOnce(new Error('attempt 2')) + .mockResolvedValueOnce('success'); + + const promise = ExponentialBackoff.run(fn, () => {}, { + baseDelay: 1_000, + growthRate: 4, + jitter: 0, + maxDelay: 2_000, + maxAttempts: 5, + }); + + await Promise.resolve(); + expect(fn).toHaveBeenCalledTimes(1); + + // attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap. + await vi.advanceTimersByTimeAsync(1_000); + expect(fn).toHaveBeenCalledTimes(2); + + // attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms. + await vi.advanceTimersByTimeAsync(2_000); + expect(fn).toHaveBeenCalledTimes(3); + + await expect(promise).resolves.toBe('success'); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that jitter scales the capped delay up or down by jitter * cappedDelay based on Math.random. + */ +const testExponentialBackoffAppliesJitter = async (): Promise => { + vi.useFakeTimers(); + // random = 1 → jitterDirection = 2*1 - 1 = +1 → full positive 10% jitter on the delay. + vi.spyOn(Math, 'random').mockReturnValue(1); + + try { + const fn = vi.fn().mockRejectedValueOnce(new Error('attempt 1')) +.mockResolvedValueOnce('success'); + + const promise = ExponentialBackoff.run(fn, () => {}, { + baseDelay: 1_000, + growthRate: 1, + jitter: 0.1, + maxDelay: 10_000, + maxAttempts: 3, + }); + + await Promise.resolve(); + expect(fn).toHaveBeenCalledTimes(1); + + // cappedDelay = 1000; with +10% jitter the retry fires after 1100ms, not 1000ms. + await vi.advanceTimersByTimeAsync(1_100); + expect(fn).toHaveBeenCalledTimes(2); + + await expect(promise).resolves.toBe('success'); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +const runTests = async (): Promise => { + test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions); + test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions); + test('ExponentialBackoff.run: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError); + test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt); + test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess); + test('ExponentialBackoff: calls onError for each failed attempt', testExponentialBackoffCallsOnErrorForEachFailure); + test('ExponentialBackoff: throws the last error when max attempts are exhausted', testExponentialBackoffThrowsLastErrorWhenExhausted); + test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows); + test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun); + test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero); + test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially); + test('ExponentialBackoff: caps delay at maxDelay', testExponentialBackoffCapsDelayAtMaxDelay); + test('ExponentialBackoff: applies jitter around the capped delay', testExponentialBackoffAppliesJitter); +}; + +await runTests(); From 09d732ab4115be8df23f3f53c6ee1cd575c4bd29 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 13 Jul 2026 01:40:57 +0000 Subject: [PATCH 02/16] simplify while loop condition --- source/exponential-backoff.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index 06b6b07..3ae558f 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -72,7 +72,10 @@ export class ExponentialBackoff { let attempt = 0; - while (attempt < this.options.maxAttempts || this.options.maxAttempts == 0) { + // If the max attempts is 0, we should continue indefinitely. + const unlimitedAttempts = this.options.maxAttempts === 0; + + while (attempt < this.options.maxAttempts || unlimitedAttempts) { try { return await taskFn(); } catch (error) { From 869b025e085ef6519ea897306f32b3ec39e68bfa Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 11 Jul 2026 12:36:52 +0000 Subject: [PATCH 03/16] Document option in exponential backoff constructor --- source/exponential-backoff.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index 3ae558f..79c672c 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -41,11 +41,23 @@ export class ExponentialBackoff { private readonly options: ExponentialBackoffOptions; - constructor(options?: Partial) { + /** + * 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 - Random proportional variation applied to each delay. Default: `0.1`. + */ + constructor(options: Partial = {}) { this.options = { - maxDelay: 10000, + maxDelay: 10_000, maxAttempts: 10, - baseDelay: 1000, + baseDelay: 1_000, growthRate: 2, jitter: 0.1, ...options, From 06cc8eff25166e1a12dc1aa48d42a80d703acab7 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 11 Jul 2026 12:54:14 +0000 Subject: [PATCH 04/16] Throw Exponential Backoff error containing all execution errors --- source/errors.ts | 4 ++-- source/exponential-backoff.ts | 16 ++++++++------ test/exponential-backoff.test.ts | 37 ++++++++++++++++++++++---------- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/source/errors.ts b/source/errors.ts index 37f1113..12ecccb 100644 --- a/source/errors.ts +++ b/source/errors.ts @@ -2,8 +2,8 @@ * Error thrown when the maximum number of retries is hit in an exponential backoff */ export class ExponentialBackoffMaxRetriesHitError extends Error { - constructor() { - super('Exponential backoff: Max retries hit'); + constructor(errors: Array) { + super('Exponential backoff: Max retries hit', { cause: errors }); this.name = 'ExponentialBackoffMaxRetriesHitError'; } } diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index 79c672c..ce16f00 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -70,17 +70,18 @@ export class ExponentialBackoff { * 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, the last error will be thrown + * 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 fn - The function to run * @param onError - The callback to call when an error occurs * - * @throws The last error if the function fails and we have hit the max attempts + * @throws An ExponentialBackoffMaxRetriesHitError with all the errors that were thrown by the task function * * @returns The result of the function */ async run(taskFn: () => Promise, onError = (_error: Error): void => {}): Promise { - let lastError: Error = new ExponentialBackoffMaxRetriesHitError(); + // Initialize an empty array to store the errors + const errors: Error[] = []; let attempt = 0; @@ -92,8 +93,9 @@ export class ExponentialBackoff { return await taskFn(); } catch (error) { // Store the error in case we fail every attempt - lastError = error instanceof Error ? error : new Error(`${error}`); - onError(lastError); + const errorInstance = error instanceof Error ? error : new Error(`${error}`); + errors.push(errorInstance); + onError(errorInstance); // Wait before going to the next attempt const delay = ExponentialBackoff.calculateDelay(this.options, attempt); @@ -103,8 +105,8 @@ export class ExponentialBackoff { attempt++; } - // We completed the loop without ever succeeding. Throw the last error we got - throw lastError; + // We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got + throw new ExponentialBackoffMaxRetriesHitError(errors); } /** diff --git a/test/exponential-backoff.test.ts b/test/exponential-backoff.test.ts index ed9acca..e51cf73 100644 --- a/test/exponential-backoff.test.ts +++ b/test/exponential-backoff.test.ts @@ -1,5 +1,6 @@ import { expect, test, vi } from 'vitest'; import { ExponentialBackoff } from '../source/exponential-backoff.ts'; +import { ExponentialBackoffMaxRetriesHitError } from '../source/errors.ts'; /** * Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance @@ -114,7 +115,7 @@ const testExponentialBackoffRetriesUntilSuccess = async (): Promise => { /** * Tests that the onError callback is invoked once for every failed attempt, including the last one - * before the final rejection is thrown to the caller. + * before an ExponentialBackoffMaxRetriesHitError is thrown to the caller. */ const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise => { const error = new Error('temporary failure'); @@ -128,29 +129,35 @@ const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise baseDelay: 0, jitter: 0, maxAttempts: 3, - })).rejects.toThrow('temporary failure'); + })).rejects.toThrow(ExponentialBackoffMaxRetriesHitError); expect(onError).toHaveBeenCalledTimes(3); expect(onError).toHaveBeenCalledWith(error); }; /** - * Tests that when all attempts are exhausted the caller receives the error from the final attempt, - * not an earlier one. + * Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError + * with every task error preserved in order on the cause. */ -const testExponentialBackoffThrowsLastErrorWhenExhausted = async (): Promise => { +const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Promise => { const firstError = new Error('first'); const lastError = new Error('last'); - // Two distinct errors so we can prove the last one surfaces. + // Two distinct errors so we can prove both are collected, not just the last one. const fn = vi.fn().mockRejectedValueOnce(firstError) .mockRejectedValueOnce(lastError); - await expect(ExponentialBackoff.run(fn, () => {}, { + try { + await ExponentialBackoff.run(fn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 2, - })).rejects.toThrow('last'); + }); + expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError); + expect((error as ExponentialBackoffMaxRetriesHitError).cause).toEqual([firstError, lastError]); + } expect(fn).toHaveBeenCalledTimes(2); }; @@ -165,11 +172,19 @@ const testExponentialBackoffWrapsNonErrorThrows = async (): Promise => { const onError = vi.fn(); // Single attempt — we fail fast and inspect what onError received. - await expect(ExponentialBackoff.run(fn, onError, { + try { + await ExponentialBackoff.run(fn, onError, { baseDelay: 0, jitter: 0, maxAttempts: 1, - })).rejects.toThrow('not-an-error'); + }); + 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); @@ -340,7 +355,7 @@ const runTests = async (): Promise => { test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt); test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess); test('ExponentialBackoff: calls onError for each failed attempt', testExponentialBackoffCallsOnErrorForEachFailure); - test('ExponentialBackoff: throws the last error when max attempts are exhausted', testExponentialBackoffThrowsLastErrorWhenExhausted); + test('ExponentialBackoff: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted', testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted); test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows); test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun); test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero); From e726d1c25aea1bad2cbdf4bbec01aeaecbfd39f5 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 11 Jul 2026 12:54:42 +0000 Subject: [PATCH 05/16] Document await in exponential backoff run function --- source/exponential-backoff.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index ce16f00..b19bdeb 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -90,6 +90,8 @@ export class ExponentialBackoff { while (attempt < this.options.maxAttempts || unlimitedAttempts) { 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(); } catch (error) { // Store the error in case we fail every attempt From 452f5acdd58410497f75216f4f74bc33561cb371 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 13 Jul 2026 01:49:36 +0000 Subject: [PATCH 06/16] Formatting --- test/exponential-backoff.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/exponential-backoff.test.ts b/test/exponential-backoff.test.ts index e51cf73..d49137c 100644 --- a/test/exponential-backoff.test.ts +++ b/test/exponential-backoff.test.ts @@ -156,7 +156,7 @@ const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Pr expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError); - expect((error as ExponentialBackoffMaxRetriesHitError).cause).toEqual([firstError, lastError]); + expect((error as ExponentialBackoffMaxRetriesHitError).cause).toEqual([ firstError, lastError ]); } expect(fn).toHaveBeenCalledTimes(2); @@ -181,7 +181,7 @@ const testExponentialBackoffWrapsNonErrorThrows = async (): Promise => { expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError); - const [wrappedError] = (error as ExponentialBackoffMaxRetriesHitError).cause as Error[]; + const [ wrappedError ] = (error as ExponentialBackoffMaxRetriesHitError).cause as Error[]; expect(wrappedError).toBeInstanceOf(Error); expect(wrappedError.message).toBe('not-an-error'); } @@ -355,7 +355,10 @@ const runTests = async (): Promise => { 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: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted', + testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted, + ); test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows); test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun); test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero); From f346a1fc4f99441083a5d498b30f0e30609772a6 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 13 Jul 2026 02:21:37 +0000 Subject: [PATCH 07/16] Actually simplify the while look condition --- source/exponential-backoff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index b19bdeb..f2cd4b0 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -88,7 +88,7 @@ export class ExponentialBackoff { // If the max attempts is 0, we should continue indefinitely. const unlimitedAttempts = this.options.maxAttempts === 0; - while (attempt < this.options.maxAttempts || unlimitedAttempts) { + while (unlimitedAttempts || attempt < this.options.maxAttempts) { 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. From bce4c1552e42b71a45a24e855834923e4aad3b89 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 13 Jul 2026 22:07:40 +1000 Subject: [PATCH 08/16] Make jitter subtract only --- source/exponential-backoff.ts | 18 ++++++++---------- test/exponential-backoff.test.ts | 17 ++++++++++------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index f2cd4b0..ec97505 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -5,7 +5,7 @@ import { ExponentialBackoffMaxRetriesHitError } from './errors.ts'; * * The delay increases exponentially with each attempt, up to a maximum delay. * - * The jitter is a random amount of time added to the delay to prevent thundering herd problems. + * 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. */ @@ -51,7 +51,7 @@ export class ExponentialBackoff { * @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 - Random proportional variation applied to each delay. Default: `0.1`. + * @param options.jitter - Maximum proportional reduction subtracted from each delay (0–1). Default: `0.1`. */ constructor(options: Partial = {}) { this.options = { @@ -114,8 +114,6 @@ export class ExponentialBackoff { /** * Calculate the delay before we should attempt to retry * - * NOTE: The maximum delay is (maxDelay * (1 + jitter)) - * * @param attempt * @returns The time in milliseconds before another attempt should be made */ @@ -129,14 +127,14 @@ export class ExponentialBackoff { // 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 the jitter direction. This will be between -1 and 1 - const jitterDirection = 2 * Math.random() - 1; + // Get a random number for the amount to "jitter" the delay by + const jitterAmount = Math.random(); // Calculate the jitter - const jitter = jitterDirection * options.jitter * cappedDelay; + const jitter = jitterAmount * options.jitter * cappedDelay; - // Add the jitter to the delay - return cappedDelay + jitter; + // Subtract the jitter from the delay + return cappedDelay - jitter; } } @@ -163,7 +161,7 @@ export type ExponentialBackoffOptions = { growthRate: number; /** - * The jitter of the delay as a percentage of growthRate + * The jitter of the delay as a percentage of growthRate. The jitter is subtracted from the delay. */ jitter: number; }; diff --git a/test/exponential-backoff.test.ts b/test/exponential-backoff.test.ts index d49137c..23f30b3 100644 --- a/test/exponential-backoff.test.ts +++ b/test/exponential-backoff.test.ts @@ -9,8 +9,8 @@ import { ExponentialBackoffMaxRetriesHitError } from '../source/errors.ts'; const testExponentialBackoffRunUsesDefaultOptions = async (): Promise => { // Fake timers let us advance time without waiting real seconds between retries. vi.useFakeTimers(); - // Pin Math.random so jitter does not randomize the delay we are about to measure. - vi.spyOn(Math, 'random').mockReturnValue(0.5); + // 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. @@ -315,11 +315,11 @@ const testExponentialBackoffCapsDelayAtMaxDelay = async (): Promise => { }; /** - * Tests that jitter scales the capped delay up or down by jitter * cappedDelay based on Math.random. + * Tests that jitter subtracts up to jitter * cappedDelay from the capped delay based on Math.random. */ const testExponentialBackoffAppliesJitter = async (): Promise => { vi.useFakeTimers(); - // random = 1 → jitterDirection = 2*1 - 1 = +1 → full positive 10% jitter on the delay. + // random = 1 → full 10% reduction: 1000 - (1 * 0.1 * 1000) = 900ms. vi.spyOn(Math, 'random').mockReturnValue(1); try { @@ -337,8 +337,11 @@ const testExponentialBackoffAppliesJitter = async (): Promise => { await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(1); - // cappedDelay = 1000; with +10% jitter the retry fires after 1100ms, not 1000ms. - await vi.advanceTimersByTimeAsync(1_100); + // Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry. + await vi.advanceTimersByTimeAsync(899); + expect(fn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); expect(fn).toHaveBeenCalledTimes(2); await expect(promise).resolves.toBe('success'); @@ -364,7 +367,7 @@ const runTests = async (): Promise => { test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero); test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially); test('ExponentialBackoff: caps delay at maxDelay', testExponentialBackoffCapsDelayAtMaxDelay); - test('ExponentialBackoff: applies jitter around the capped delay', testExponentialBackoffAppliesJitter); + test('ExponentialBackoff: subtracts jitter from the capped delay', testExponentialBackoffAppliesJitter); }; await runTests(); From 873a07532914afc89f43b10cb0cc70d3703847a8 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 18 Jul 2026 13:18:01 +0000 Subject: [PATCH 09/16] Add abort signal to exponential backoff --- source/errors.ts | 13 ++++++ source/exponential-backoff.ts | 74 +++++++++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/source/errors.ts b/source/errors.ts index 12ecccb..02b32f7 100644 --- a/source/errors.ts +++ b/source/errors.ts @@ -7,3 +7,16 @@ export class ExponentialBackoffMaxRetriesHitError extends Error { 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 index ec97505..f943344 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -1,4 +1,4 @@ -import { ExponentialBackoffMaxRetriesHitError } from './errors.ts'; +import { ExponentialBackoffStoppedRetriesError, ExponentialBackoffMaxRetriesHitError } from './errors.ts'; /** * Exponential backoff is a technique used to retry a function after a delay. @@ -29,11 +29,15 @@ export class ExponentialBackoff { * @param onError - The callback to call when an error occurs * @param options - The configuration for the exponential backoff * - * @throws The last error if the function fails and we have hit the max attempts + * @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function * * @returns The result of the function */ - static run(taskFn: () => Promise, onError = (_error: Error): void => {}, options?: Partial): Promise { + static run( + taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise, + onError = (_error: Error): void => {}, + options?: Partial, + ): Promise { const backoff = ExponentialBackoff.from(options); return backoff.run(taskFn, onError); @@ -75,35 +79,63 @@ export class ExponentialBackoff { * @param fn - The function to run * @param onError - The callback to call when an error occurs * - * @throws An ExponentialBackoffMaxRetriesHitError with all the errors that were thrown by the task function + * @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function * * @returns The result of the function */ - async run(taskFn: () => Promise, onError = (_error: Error): void => {}): Promise { + 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; - while (unlimitedAttempts || attempt < this.options.maxAttempts) { + // 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(); + return await taskFn({ stopRetries }); } catch (error) { // Store the error in case we fail every attempt const errorInstance = error instanceof Error ? error : new Error(`${error}`); - errors.push(errorInstance); onError(errorInstance); - // Wait before going to the next attempt - const delay = ExponentialBackoff.calculateDelay(this.options, attempt); - await new Promise((resolve) => setTimeout(resolve, delay)); + // If we have unlimited attemps, 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++; } @@ -114,7 +146,8 @@ export class ExponentialBackoff { /** * Calculate the delay before we should attempt to retry * - * @param attempt + * @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 { @@ -165,3 +198,20 @@ export type ExponentialBackoffOptions = { */ 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; +}; From 798ccdf32cefdd037bb09e6aaf26f8e6dea0d97f Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 18 Jul 2026 13:53:04 +0000 Subject: [PATCH 10/16] Relocate statics below constructor --- source/exponential-backoff.ts | 94 +++++++++++++++++------------------ 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index f943344..47259bc 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -10,6 +10,31 @@ import { ExponentialBackoffStoppedRetriesError, ExponentialBackoffMaxRetriesHitE * The growth rate is the factor by which the delay increases with each attempt. */ export class ExponentialBackoff { + private 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, + }; + } + /** * Create a new ExponentialBackoff instance * @@ -43,29 +68,31 @@ export class ExponentialBackoff { return backoff.run(taskFn, onError); } - private readonly options: ExponentialBackoffOptions; - /** - * Creates a new exponential-backoff instance. + * Calculate the delay before we should attempt to retry * - * 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`. + * @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 */ - constructor(options: Partial = {}) { - this.options = { - maxDelay: 10_000, - maxAttempts: 10, - baseDelay: 1_000, - growthRate: 2, - jitter: 0.1, - ...options, - }; + 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; } /** @@ -142,33 +169,6 @@ export class ExponentialBackoff { // 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 - */ - 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; - } } export type ExponentialBackoffOptions = { From 7d40a40ea9f85e081aa4ec60e9310502c2109023 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 18 Jul 2026 13:56:43 +0000 Subject: [PATCH 11/16] use js private --- source/exponential-backoff.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index 47259bc..94ff838 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -10,7 +10,7 @@ import { ExponentialBackoffStoppedRetriesError, ExponentialBackoffMaxRetriesHitE * The growth rate is the factor by which the delay increases with each attempt. */ export class ExponentialBackoff { - private readonly options: ExponentialBackoffOptions; + readonly #options: ExponentialBackoffOptions; /** * Creates a new exponential-backoff instance. @@ -25,7 +25,7 @@ export class ExponentialBackoff { * @param options.jitter - Maximum proportional reduction subtracted from each delay (0–1). Default: `0.1`. */ constructor(options: Partial = {}) { - this.options = { + this.#options = { maxDelay: 10_000, maxAttempts: 10, baseDelay: 1_000, @@ -125,7 +125,7 @@ export class ExponentialBackoff { let attempt = 0; // If the max attempts is 0, we should continue indefinitely. - const unlimitedAttempts = this.options.maxAttempts === 0; + const unlimitedAttempts = this.#options.maxAttempts === 0; // Loop until we succeed, hit the max attempts, or the abort signal is activated while (true) { @@ -146,7 +146,7 @@ export class ExponentialBackoff { // 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; + const nextAttemptExceedsMaxAttempts = nextAttemptCount >= this.#options.maxAttempts; // If the next attempt exceeds the max attempts, break out of the loop if (!unlimitedAttempts && nextAttemptExceedsMaxAttempts) { @@ -160,7 +160,7 @@ export class ExponentialBackoff { } // Wait before going to the next attempt - const delay = ExponentialBackoff.calculateDelay(this.options, attempt); + const delay = ExponentialBackoff.calculateDelay(this.#options, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); attempt++; From 448ef5ca618ca6ff9ead8f0e4f487b93a8881a0c Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 18 Jul 2026 16:12:17 +0000 Subject: [PATCH 12/16] Rename mock functions in Vi --- test/exponential-backoff.test.ts | 88 ++++++++++++++++---------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/test/exponential-backoff.test.ts b/test/exponential-backoff.test.ts index 23f30b3..75e781c 100644 --- a/test/exponential-backoff.test.ts +++ b/test/exponential-backoff.test.ts @@ -15,22 +15,22 @@ const testExponentialBackoffRunUsesDefaultOptions = async (): Promise => { try { // The wrapped function fails on its first invocation and succeeds on the second. // That forces ExponentialBackoff.run down the retry path using default options. - const fn = vi.fn().mockRejectedValueOnce(new Error('retry me')) + 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(fn); + const promise = ExponentialBackoff.run(rejectThenResolveFn); // Yield one microtask so the first (immediate) attempt completes and schedules the retry timer. await Promise.resolve(); - expect(fn).toHaveBeenCalledTimes(1); + 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(fn).toHaveBeenCalledTimes(2); + expect(rejectThenResolveFn).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); vi.restoreAllMocks(); @@ -43,19 +43,19 @@ const testExponentialBackoffRunUsesDefaultOptions = async (): Promise => { */ const testExponentialBackoffRunWithPartialOptions = async (): Promise => { // Same fail-then-succeed pattern; we only care that partial options still enable a retry. - const fn = vi.fn().mockRejectedValueOnce(new Error('retry me')) + 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(fn, undefined, { + const result = await ExponentialBackoff.run(rejectThenResolveFn, undefined, { baseDelay: 0, jitter: 0, maxAttempts: 3, }); expect(result).toBe('done'); - expect(fn).toHaveBeenCalledTimes(2); + expect(rejectThenResolveFn).toHaveBeenCalledTimes(2); }; /** @@ -63,16 +63,16 @@ const testExponentialBackoffRunWithPartialOptions = async (): Promise => { * the instance's stored options when no per-run options are supplied. */ const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise => { - const fn = vi.fn().mockRejectedValueOnce(new Error('retry me')) + 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(fn); + const result = await backoff.run(rejectThenResolveFn); expect(result).toBe('instance-result'); - expect(fn).toHaveBeenCalledTimes(2); + expect(rejectThenResolveFn).toHaveBeenCalledTimes(2); }; /** @@ -80,16 +80,16 @@ const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise => { // Always resolves — never enters the catch/retry branch. - const fn = vi.fn(async () => 'success'); + const resolveFn = vi.fn(async () => 'success'); const onError = vi.fn(); - const result = await ExponentialBackoff.run(fn, onError, { + const result = await ExponentialBackoff.run(resolveFn, onError, { baseDelay: 0, jitter: 0, }); expect(result).toBe('success'); - expect(fn).toHaveBeenCalledOnce(); + expect(resolveFn).toHaveBeenCalledOnce(); expect(onError).not.toHaveBeenCalled(); }; @@ -98,19 +98,19 @@ const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise => */ const testExponentialBackoffRetriesUntilSuccess = async (): Promise => { // Three invocations: two rejections then a success on the third call. - const fn = vi.fn().mockRejectedValueOnce(new Error('attempt 1')) + 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(fn, () => {}, { + const result = await ExponentialBackoff.run(tripleRejectFn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 5, }); expect(result).toBe('success'); - expect(fn).toHaveBeenCalledTimes(3); + expect(tripleRejectFn).toHaveBeenCalledTimes(3); }; /** @@ -121,11 +121,11 @@ const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise const error = new Error('temporary failure'); // Always rejects with the same error — we will exhaust all attempts. - const fn = vi.fn().mockRejectedValue(error); + const rejectFn = vi.fn().mockRejectedValue(error); const onError = vi.fn(); // maxAttempts: 3 means three tries total, all of which will fail. - await expect(ExponentialBackoff.run(fn, onError, { + await expect(ExponentialBackoff.run(rejectFn, onError, { baseDelay: 0, jitter: 0, maxAttempts: 3, @@ -144,11 +144,11 @@ const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Pr const lastError = new Error('last'); // Two distinct errors so we can prove both are collected, not just the last one. - const fn = vi.fn().mockRejectedValueOnce(firstError) + const doubleRejectFn = vi.fn().mockRejectedValueOnce(firstError) .mockRejectedValueOnce(lastError); try { - await ExponentialBackoff.run(fn, () => {}, { + await ExponentialBackoff.run(doubleRejectFn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 2, @@ -159,7 +159,7 @@ const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Pr expect((error as ExponentialBackoffMaxRetriesHitError).cause).toEqual([ firstError, lastError ]); } - expect(fn).toHaveBeenCalledTimes(2); + expect(doubleRejectFn).toHaveBeenCalledTimes(2); }; /** @@ -168,12 +168,12 @@ const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Pr */ const testExponentialBackoffWrapsNonErrorThrows = async (): Promise => { // Reject with a plain string — not an Error subclass. - const fn = vi.fn().mockRejectedValue('not-an-error'); + 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(fn, onError, { + await ExponentialBackoff.run(rejectedFn, onError, { baseDelay: 0, jitter: 0, maxAttempts: 1, @@ -196,7 +196,7 @@ const testExponentialBackoffWrapsNonErrorThrows = async (): Promise => { * as an alternative to the static helper. */ const testExponentialBackoffFromAndInstanceRun = async (): Promise => { - const fn = vi.fn(async () => 42); + 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({ @@ -204,10 +204,10 @@ const testExponentialBackoffFromAndInstanceRun = async (): Promise => { jitter: 0, }); - const result = await backoff.run(fn); + const result = await backoff.run(successfullyResolve); expect(result).toBe(42); - expect(fn).toHaveBeenCalledOnce(); + expect(successfullyResolve).toHaveBeenCalledOnce(); }; /** @@ -215,21 +215,21 @@ const testExponentialBackoffFromAndInstanceRun = async (): Promise => { */ const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (): Promise => { // Four invocations: three failures then success — would exceed a cap of 3 if one existed. - const fn = vi + 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(fn, () => {}, { + const result = await ExponentialBackoff.run(tripleRejectThenResolveFn, () => {}, { baseDelay: 0, jitter: 0, maxAttempts: 0, }); expect(result).toBe('eventually'); - expect(fn).toHaveBeenCalledTimes(4); + expect(tripleRejectThenResolveFn).toHaveBeenCalledTimes(4); }; /** @@ -241,13 +241,13 @@ const testExponentialBackoffIncreasesDelayExponentially = async (): Promise {}, { + const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, { baseDelay: 100, growthRate: 2, jitter: 0, @@ -257,15 +257,15 @@ const testExponentialBackoffIncreasesDelayExponentially = async (): Promise => { vi.spyOn(Math, 'random').mockReturnValue(0.5); try { - const fn = vi + const doubleRejectThenResolveFn = vi .fn() .mockRejectedValueOnce(new Error('attempt 1')) .mockRejectedValueOnce(new Error('attempt 2')) .mockResolvedValueOnce('success'); - const promise = ExponentialBackoff.run(fn, () => {}, { + const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, { baseDelay: 1_000, growthRate: 4, jitter: 0, @@ -297,15 +297,15 @@ const testExponentialBackoffCapsDelayAtMaxDelay = async (): Promise => { }); await Promise.resolve(); - expect(fn).toHaveBeenCalledTimes(1); + expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1); // attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap. await vi.advanceTimersByTimeAsync(1_000); - expect(fn).toHaveBeenCalledTimes(2); + expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2); // attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms. await vi.advanceTimersByTimeAsync(2_000); - expect(fn).toHaveBeenCalledTimes(3); + expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3); await expect(promise).resolves.toBe('success'); } finally { @@ -323,10 +323,10 @@ const testExponentialBackoffAppliesJitter = async (): Promise => { vi.spyOn(Math, 'random').mockReturnValue(1); try { - const fn = vi.fn().mockRejectedValueOnce(new Error('attempt 1')) + const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1')) .mockResolvedValueOnce('success'); - const promise = ExponentialBackoff.run(fn, () => {}, { + const promise = ExponentialBackoff.run(rejectThenResolveFn, () => {}, { baseDelay: 1_000, growthRate: 1, jitter: 0.1, @@ -335,14 +335,14 @@ const testExponentialBackoffAppliesJitter = async (): Promise => { }); await Promise.resolve(); - expect(fn).toHaveBeenCalledTimes(1); + expect(rejectThenResolveFn).toHaveBeenCalledTimes(1); // Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry. await vi.advanceTimersByTimeAsync(899); - expect(fn).toHaveBeenCalledTimes(1); + expect(rejectThenResolveFn).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); - expect(fn).toHaveBeenCalledTimes(2); + expect(rejectThenResolveFn).toHaveBeenCalledTimes(2); await expect(promise).resolves.toBe('success'); } finally { From 23ecfbd0322a5fd39971254e50af01c293ef6522 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 18 Jul 2026 16:24:00 +0000 Subject: [PATCH 13/16] Validate options on Exponential Backoff --- source/exponential-backoff.ts | 56 +++++++++++++ test/exponential-backoff.test.ts | 134 ++++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index 94ff838..32b4355 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -33,6 +33,8 @@ export class ExponentialBackoff { jitter: 0.1, ...options, }; + + ExponentialBackoff.validateOptions(this.#options); } /** @@ -95,6 +97,60 @@ export class ExponentialBackoff { 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 * diff --git a/test/exponential-backoff.test.ts b/test/exponential-backoff.test.ts index 75e781c..4e073e8 100644 --- a/test/exponential-backoff.test.ts +++ b/test/exponential-backoff.test.ts @@ -2,6 +2,17 @@ import { expect, test, vi } from 'vitest'; import { ExponentialBackoff } from '../source/exponential-backoff.ts'; import { ExponentialBackoffMaxRetriesHitError } 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. @@ -98,9 +109,11 @@ const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise => */ 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'); + 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, () => {}, { @@ -351,6 +364,116 @@ const testExponentialBackoffAppliesJitter = async (): Promise => { } }; +/** + * 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); @@ -368,6 +491,11 @@ const runTests = async (): Promise => { 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(); From 96483cf4452c876f66ff71553209e8eaddba38fd Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sat, 18 Jul 2026 16:26:07 +0000 Subject: [PATCH 14/16] Documentation and spelling --- source/exponential-backoff.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index 32b4355..b13274f 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -57,6 +57,7 @@ export class ExponentialBackoff { * @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 */ @@ -163,6 +164,7 @@ export class ExponentialBackoff { * @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 */ @@ -194,7 +196,7 @@ export class ExponentialBackoff { const errorInstance = error instanceof Error ? error : new Error(`${error}`); onError(errorInstance); - // If we have unlimited attemps, don't append this to the errors array to prevent a memory leak. + // If we have unlimited attempts, don't append this to the errors array to prevent a memory leak. if (!unlimitedAttempts) { errors.push(errorInstance); } From f178b2e6fd5184dcb9091a50e18b2be8470d7509 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sun, 19 Jul 2026 16:59:20 +0000 Subject: [PATCH 15/16] Fix fn comment --- source/exponential-backoff.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/exponential-backoff.ts b/source/exponential-backoff.ts index b13274f..8b7b8a8 100644 --- a/source/exponential-backoff.ts +++ b/source/exponential-backoff.ts @@ -52,7 +52,7 @@ export class ExponentialBackoff { /** * Run the function with exponential backoff * - * @param fn - The function to run + * @param taskFn - The function to run * @param onError - The callback to call when an error occurs * @param options - The configuration for the exponential backoff * @@ -160,7 +160,7 @@ export class ExponentialBackoff { * * 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 fn - The function to run + * @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 From 7cb9a29e7716a3148c38f44f289a745190c771ab Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sun, 19 Jul 2026 17:15:31 +0000 Subject: [PATCH 16/16] Add tests for abort fn in exponential backoff --- test/exponential-backoff.test.ts | 96 +++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/test/exponential-backoff.test.ts b/test/exponential-backoff.test.ts index 4e073e8..7c29a96 100644 --- a/test/exponential-backoff.test.ts +++ b/test/exponential-backoff.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vitest'; import { ExponentialBackoff } from '../source/exponential-backoff.ts'; -import { ExponentialBackoffMaxRetriesHitError } from '../source/errors.ts'; +import { ExponentialBackoffMaxRetriesHitError, ExponentialBackoffStoppedRetriesError } from '../source/errors.ts'; /** * A valid options object that satisfies {@link ExponentialBackoff.validateOptions}. @@ -204,6 +204,97 @@ const testExponentialBackoffWrapsNonErrorThrows = async (): Promise => { 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. @@ -486,6 +577,9 @@ const runTests = async (): Promise => { 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);