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; +};