Files
xo-cash-utils/source/exponential-backoff/exponential-backoff-externally-aborted.ts
T

97 lines
4.0 KiB
TypeScript

import {
ExternallyAbortedExponentialBackoffExternalSignalAbortedError,
ExternallyAbortedExponentialBackoffInternalSignalAbortedError,
} from './errors.ts';
import { ExponentialBackoff } from './exponential-backoff.ts';
import type { ExponentialBackoffCallbackParameters, ExponentialBackoffOptions, ExponentialBackoffRunOptions } from './exponential-backoff.ts';
/**
* Options for the ExponentialBackoffExternallyAbortable class
*
* @extends Partial<ExponentialBackoffOptions>
* @property abortSignal - The abort signal to use for this instance
*/
export type ExponentialBackoffExternallyAbortableOptions = Partial<ExponentialBackoffOptions> & {
abortSignal: AbortSignal;
};
/**
* An exponential backoff that can be stopped by calling `.abort()` or by passing an
* `abortSignal` to the constructor.
*
* @remarks One instance can run many tasks. Aborting it stops retries for every run.
*/
export class ExponentialBackoffExternallyAbortable {
readonly #abortController = new AbortController();
readonly #exponentialBackoff: ExponentialBackoff;
constructor(options: Partial<ExponentialBackoffExternallyAbortableOptions> = {}) {
const { abortSignal, ...backoffOptions } = options;
this.#exponentialBackoff = new ExponentialBackoff(backoffOptions);
// Listen for the provided abort signal to be aborted
abortSignal?.addEventListener(
'abort',
() => {
this.#abortController.abort(new ExternallyAbortedExponentialBackoffExternalSignalAbortedError(abortSignal.reason));
},
{
once: true,
// Clean up this listener if the internal abort controller is aborted
signal: this.#abortController.signal,
},
);
// If the abort signal is already aborted, immediately abort the internal abort controller
if (abortSignal?.aborted) {
this.#abortController.abort(new ExternallyAbortedExponentialBackoffExternalSignalAbortedError(abortSignal.reason));
}
}
/**
* 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 every attempt fails, an ExponentialBackoffMaxRetriesHitError will be thrown
* with all errors from the task function.
*
* @param taskFn - The function to run
* @param options - Options that control this run
*
* @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
* @throws An {@link ExternallyAbortedExponentialBackoffExternalSignalAbortedError}
* if the abort signal that was provided during construction is activated
* @throws An {@link ExternallyAbortedExponentialBackoffInternalSignalAbortedError}
* if {@link ExponentialBackoffExternallyAbortable.abort} is called on this class
*
* @returns The result of the function
*/
public run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
options: Partial<ExponentialBackoffRunOptions> = {},
): Promise<T> {
let signal = this.#abortController.signal;
// If a signal was provided for this run, compose it with the instance-wide signal
if (options.signal !== undefined) {
signal = AbortSignal.any([ signal, options.signal ]);
}
// Call the exponential backoff run method with the composed signal
return this.#exponentialBackoff.run(taskFn, { ...options, signal });
}
/**
* Stops retries for all current and future runs.
*
* @param reason - The reason for stopping retries
*/
public abort(reason: unknown): void {
this.#abortController.abort(new ExternallyAbortedExponentialBackoffInternalSignalAbortedError(reason));
}
}