Throw Exponential Backoff error containing all execution errors
This commit is contained in:
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user