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);