Throw Exponential Backoff error containing all execution errors

This commit is contained in:
2026-07-11 12:54:14 +00:00
parent 869b025e08
commit 06cc8eff25
3 changed files with 37 additions and 20 deletions

View File

@@ -2,8 +2,8 @@
* Error thrown when the maximum number of retries is hit in an exponential backoff * Error thrown when the maximum number of retries is hit in an exponential backoff
*/ */
export class ExponentialBackoffMaxRetriesHitError extends Error { export class ExponentialBackoffMaxRetriesHitError extends Error {
constructor() { constructor(errors: Array<Error>) {
super('Exponential backoff: Max retries hit'); super('Exponential backoff: Max retries hit', { cause: errors });
this.name = 'ExponentialBackoffMaxRetriesHitError'; this.name = 'ExponentialBackoffMaxRetriesHitError';
} }
} }

View File

@@ -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 * 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 * 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 fn - The function to run
* @param onError - The callback to call when an error occurs * @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 * @returns The result of the function
*/ */
async run<T>(taskFn: () => Promise<T>, onError = (_error: Error): void => {}): Promise<T> { async run<T>(taskFn: () => Promise<T>, onError = (_error: Error): void => {}): Promise<T> {
let lastError: Error = new ExponentialBackoffMaxRetriesHitError(); // Initialize an empty array to store the errors
const errors: Error[] = [];
let attempt = 0; let attempt = 0;
@@ -92,8 +93,9 @@ export class ExponentialBackoff {
return await taskFn(); return await taskFn();
} catch (error) { } catch (error) {
// Store the error in case we fail every attempt // Store the error in case we fail every attempt
lastError = error instanceof Error ? error : new Error(`${error}`); const errorInstance = error instanceof Error ? error : new Error(`${error}`);
onError(lastError); errors.push(errorInstance);
onError(errorInstance);
// Wait before going to the next attempt // Wait before going to the next attempt
const delay = ExponentialBackoff.calculateDelay(this.options, attempt); const delay = ExponentialBackoff.calculateDelay(this.options, attempt);
@@ -103,8 +105,8 @@ export class ExponentialBackoff {
attempt++; attempt++;
} }
// We completed the loop without ever succeeding. Throw the last error we got // We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got
throw lastError; throw new ExponentialBackoffMaxRetriesHitError(errors);
} }
/** /**

View File

@@ -1,5 +1,6 @@
import { expect, test, vi } from 'vitest'; import { expect, test, vi } from 'vitest';
import { ExponentialBackoff } from '../source/exponential-backoff.ts'; 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 * Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance
@@ -114,7 +115,7 @@ const testExponentialBackoffRetriesUntilSuccess = async (): Promise<void> => {
/** /**
* Tests that the onError callback is invoked once for every failed attempt, including the last one * 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<void> => { const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise<void> => {
const error = new Error('temporary failure'); const error = new Error('temporary failure');
@@ -128,29 +129,35 @@ const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise<void>
baseDelay: 0, baseDelay: 0,
jitter: 0, jitter: 0,
maxAttempts: 3, maxAttempts: 3,
})).rejects.toThrow('temporary failure'); })).rejects.toThrow(ExponentialBackoffMaxRetriesHitError);
expect(onError).toHaveBeenCalledTimes(3); expect(onError).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenCalledWith(error); expect(onError).toHaveBeenCalledWith(error);
}; };
/** /**
* Tests that when all attempts are exhausted the caller receives the error from the final attempt, * Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError
* not an earlier one. * with every task error preserved in order on the cause.
*/ */
const testExponentialBackoffThrowsLastErrorWhenExhausted = async (): Promise<void> => { const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Promise<void> => {
const firstError = new Error('first'); const firstError = new Error('first');
const lastError = new Error('last'); 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) const fn = vi.fn().mockRejectedValueOnce(firstError)
.mockRejectedValueOnce(lastError); .mockRejectedValueOnce(lastError);
await expect(ExponentialBackoff.run(fn, () => {}, { try {
await ExponentialBackoff.run(fn, () => {}, {
baseDelay: 0, baseDelay: 0,
jitter: 0, jitter: 0,
maxAttempts: 2, 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); expect(fn).toHaveBeenCalledTimes(2);
}; };
@@ -165,11 +172,19 @@ const testExponentialBackoffWrapsNonErrorThrows = async (): Promise<void> => {
const onError = vi.fn(); const onError = vi.fn();
// Single attempt — we fail fast and inspect what onError received. // Single attempt — we fail fast and inspect what onError received.
await expect(ExponentialBackoff.run(fn, onError, { try {
await ExponentialBackoff.run(fn, onError, {
baseDelay: 0, baseDelay: 0,
jitter: 0, jitter: 0,
maxAttempts: 1, 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).toHaveBeenCalledOnce();
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
@@ -340,7 +355,7 @@ const runTests = async (): Promise<void> => {
test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt); test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt);
test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess); test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess);
test('ExponentialBackoff: calls onError for each failed attempt', testExponentialBackoffCallsOnErrorForEachFailure); 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: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows);
test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun); test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun);
test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero); test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero);