@geersch/retry
Version:
Backoff strategies to use when retrying a function after a given delay.
151 lines (131 loc) • 4.74 kB
text/typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
defer,
firstValueFrom,
fromEvent,
Observable,
retry as retryOperator,
switchMap,
takeUntil,
tap,
throwError,
timer,
} from 'rxjs';
import type { BackoffStrategy } from './strategies/backoff.strategy.js';
type Type<T = any> = new (...args: any[]) => T;
export type ErrorConstructor = new (...args: any[]) => Error;
/**
* Configuration options for retry behavior
*/
export interface RetryOptions {
/**
* Function to determine if retry attempts should be aborted based on the error and retry count
* @param error - The error that occurred
* @param retryCount - The current retry attempt number (1-based)
* @returns true to abort retries, false to continue
*/
abortRetry?: (error: any, retryCount: number) => boolean;
/**
* Maximum delay between retry attempts in milliseconds
* @default 30000
*/
maxDelay?: number;
/**
* Maximum number of retry attempts
* @default 5
*/
maxRetries?: number;
/**
* Factor to scale the delay returned by the backoff strategy
* Must be a positive number greater than zero
* @default 1
*/
scaleFactor?: number;
/** An AbortSignal to allow cancellation of retry attempts */
signal?: AbortSignal | (() => AbortSignal | null) | null;
/**
* Array of error constructors that should not trigger retries
* If the thrown error is an instance of any of these constructors, retries will be aborted
*/
unrecoverableErrors?: ErrorConstructor[];
}
/**
* Retries an operation using the specified backoff strategy and options
* @param operation - The operation to retry, receives the current retry count (1-based)
* @param backoffStrategy - The backoff strategy instance or constructor to use
* @param options - Configuration options for retry behavior
* @returns Promise that resolves with the operation result or rejects with the final error
*/
export async function retry<T>(
operation: (retryCount: number) => T | Promise<T>,
backoffStrategy: Type<BackoffStrategy> | BackoffStrategy,
options: RetryOptions = {},
): Promise<T> {
let attempt = 1;
return firstValueFrom(
passRetryOperatorToPipe(
defer(async () => operation(attempt)).pipe(
tap({
error: () => (attempt += 1),
}),
),
backoffStrategy,
options,
),
);
}
/**
* Creates an observable timer that emits after a delay.
* Can be aborted by providing an AbortSignal.
*/
function createTimer(due: number): Observable<0>;
function createTimer(due: number, signal: AbortSignal, error: any): Observable<0>;
function createTimer(due: number, signal?: AbortSignal, error?: any): Observable<0> {
return signal
? timer(due).pipe(takeUntil(fromEvent(signal, 'abort').pipe(switchMap(() => throwError(() => error)))))
: timer(due);
}
/**
* Internal function that applies the retry operator to an observable
* @param observable - The observable to apply retry logic to
* @param backoffStrategy - The backoff strategy instance or constructor
* @param options - Retry configuration options with defaults
* @returns Observable with retry logic applied
*/
export function passRetryOperatorToPipe<T>(
observable: Observable<T>,
backoffStrategy: Type<BackoffStrategy> | BackoffStrategy,
{ abortRetry, maxDelay = 30000, maxRetries = 5, scaleFactor = 1, signal, unrecoverableErrors = [] }: RetryOptions,
): Observable<T> {
if (scaleFactor <= 0) {
throw new TypeError(`Expected 'scaleFactor' to be a positive number greater than zero, got ${scaleFactor}.`);
}
const strategy = typeof backoffStrategy === 'function' ? new backoffStrategy() : backoffStrategy;
const generator = strategy.getGenerator(maxRetries);
const abortSignal = typeof signal === 'function' ? signal() : signal;
return observable.pipe(
retryOperator({
count: maxRetries,
delay: (error: any, retryCount: number) => {
if (abortSignal?.aborted) {
return throwError(() => error);
}
const isUnrecoverable = unrecoverableErrors.some((errorConstructor) => error instanceof errorConstructor);
if (isUnrecoverable || (abortRetry?.(error, retryCount) ?? false)) {
return throwError(() => error);
}
const { value, done } = generator.next();
if (done) {
return throwError(
() => new Error(`The backoff strategy did not yield a delay for retry attempt ${retryCount}.`),
);
}
let delay = value * scaleFactor;
if (delay > maxDelay) {
delay = maxDelay;
}
return abortSignal ? createTimer(delay, abortSignal, error) : createTimer(delay);
},
}),
);
}