From 6fe4a64562da1269543823327d629055527ccf86 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 29 Jun 2026 07:18:20 +0000 Subject: [PATCH] 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();