Merge branch 'exponential-backoff' into sse-branc

This commit is contained in:
2026-07-19 19:07:07 +00:00
4 changed files with 893 additions and 0 deletions

22
source/errors.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Error thrown when the maximum number of retries is hit in an exponential backoff
*/
export class ExponentialBackoffMaxRetriesHitError extends Error {
constructor(errors: Array<Error>) {
super('Exponential backoff: Max retries hit', { cause: errors });
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';
}
}

View File

@@ -0,0 +1,275 @@
import { ExponentialBackoffStoppedRetriesError, ExponentialBackoffMaxRetriesHitError } from './errors.ts';
/**
* Exponential backoff is a technique used to retry a function after a delay.
*
* The delay increases exponentially with each attempt, up to a maximum delay.
*
* The jitter is a random amount of time subtracted from the delay to prevent thundering herd problems.
*
* The growth rate is the factor by which the delay increases with each attempt.
*/
export class ExponentialBackoff {
readonly #options: ExponentialBackoffOptions;
/**
* Creates a new exponential-backoff instance.
*
* Unspecified options use the defaults listed below.
*
* @param options - Exponential-backoff configuration overrides.
* @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms.
* @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`.
* @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms.
* @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`.
* @param options.jitter - Maximum proportional reduction subtracted from each delay (01). Default: `0.1`.
*/
constructor(options: Partial<ExponentialBackoffOptions> = {}) {
this.#options = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
...options,
};
ExponentialBackoff.validateOptions(this.#options);
}
/**
* Create a new ExponentialBackoff instance
*
* @param config - The configuration for the exponential backoff
* @returns The ExponentialBackoff instance
*/
static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
const backoff = new ExponentialBackoff(config);
return backoff;
}
/**
* Run the function with exponential backoff
*
* @param taskFn - The function to run
* @param onError - The callback to call when an error occurs
* @param options - The configuration for the exponential backoff
*
* @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
*
* @returns The result of the function
*/
static run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
options?: Partial<ExponentialBackoffOptions>,
): Promise<T> {
const backoff = ExponentialBackoff.from(options);
return backoff.run(taskFn, onError);
}
/**
* Calculate the delay before we should attempt to retry
*
* @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 {
// Get the power of the growth rate
const power = options.growthRate ** attempt;
// Get the delay before jitter or limit
const rawDelay = options.baseDelay * power;
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
const cappedDelay = Math.min(rawDelay, options.maxDelay);
// Get a random number for the amount to "jitter" the delay by
const jitterAmount = Math.random();
// Calculate the jitter
const jitter = jitterAmount * options.jitter * cappedDelay;
// Subtract the jitter from the delay
return cappedDelay - jitter;
}
/**
* Validate the options for the exponential backoff
*
* @param options - The options to validate
*
* @throws An error if the options are invalid
*/
public static validateOptions(options: ExponentialBackoffOptions): void {
// Validate the max delay is a finite number not less than 0
if (!Number.isFinite(options.maxDelay)) {
throw new Error('maxDelay must be a finite number');
}
if (options.maxDelay < 0) {
throw new Error('maxDelay must be not less than 0');
}
// Validate the max attempts is a finite number not less than 0
if (!Number.isFinite(options.maxAttempts)) {
throw new Error('maxAttempts must be a finite number');
}
if (options.maxAttempts < 0) {
throw new Error('maxAttempts must be not less than 0');
}
// Validate the base delay is a finite number not less than 0
if (!Number.isFinite(options.baseDelay)) {
throw new Error('baseDelay must be a finite number');
}
if (options.baseDelay < 0) {
throw new Error('baseDelay must be not less than 0');
}
// Validate the growth rate is a finite number not less than 0
if (!Number.isFinite(options.growthRate)) {
throw new Error('growthRate must be a finite number');
}
if (options.growthRate < 0) {
throw new Error('growthRate must be not less than 0');
}
// Validate the jitter is a finite number not less than 0 or greater than 1
if (!Number.isFinite(options.jitter)) {
throw new Error('jitter must be a finite number');
}
if (options.jitter < 0 || options.jitter > 1) {
throw new Error('jitter must be not less than 0 or greater than 1');
}
}
/**
* 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 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 taskFn - The function to run
* @param onError - The callback to call when an error occurs
*
* @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
*
* @returns The result of the function
*/
async run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
): Promise<T> {
// 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;
// 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({ stopRetries });
} catch (error) {
// Store the error in case we fail every attempt
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
onError(errorInstance);
// If we have unlimited attempts, 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++;
}
// We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got
throw new ExponentialBackoffMaxRetriesHitError(errors);
}
}
export type ExponentialBackoffOptions = {
/**
* The maximum delay between attempts in milliseconds
*/
maxDelay: number;
/**
* The maximum number of attempts. Passing 0 will result in infinite attempts.
*/
maxAttempts: number;
/**
* The base delay between attempts in milliseconds
*/
baseDelay: number;
/**
* The growth rate of the delay
*/
growthRate: number;
/**
* The jitter of the delay as a percentage of growthRate. The jitter is subtracted from the delay.
*/
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;
};

View File

@@ -1,3 +1,4 @@
export * from './exponential-backoff.ts';
export * from './extended-json.ts';
export * from './script.ts';
export * from './sse-session/index.ts';