alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
236 lines (198 loc) • 6.82 kB
text/typescript
import { $inject } from "alepha";
import { DateTimeProvider, type DurationLike } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { RetryCancelError } from "../errors/RetryCancelError.ts";
import { RetryTimeoutError } from "../errors/RetryTimeoutError.ts";
export interface RetryOptions<T extends (...args: any[]) => any> {
/**
* The function to retry.
*/
handler: T;
/**
* The maximum number of attempts.
*
* @default 3
*/
max?: number;
/**
* The backoff strategy for delays between retries.
* Can be a fixed number (in ms) or a configuration object for exponential backoff.
*
* @default { initial: 200, factor: 2, jitter: true }
*/
backoff?: number | RetryBackoffOptions;
/**
* An overall time limit for all retry attempts combined.
*
* e.g., `[5, 'seconds']`
*/
maxDuration?: DurationLike;
/**
* A function that determines if a retry should be attempted based on the error.
*
* @default (error) => true (retries on any error)
*/
when?: (error: Error) => boolean;
/**
* A custom callback for when a retry attempt fails.
* This is called before the delay.
*/
onError?: (error: Error, attempt: number, ...args: Parameters<T>) => void;
/**
* An AbortSignal to allow for external cancellation of the retry loop.
*/
signal?: AbortSignal;
/**
* An additional AbortSignal to combine with the provided signal.
* Used internally by $retry to handle app lifecycle.
*/
additionalSignal?: AbortSignal;
}
export interface RetryBackoffOptions {
/**
* Initial delay in milliseconds.
*
* @default 200
*/
initial?: number;
/**
* Multiplier for each subsequent delay.
*
* @default 2
*/
factor?: number;
/**
* Maximum delay in milliseconds.
*/
max?: number;
/**
* If true, adds a random jitter to the delay to prevent thundering herd.
*
* @default true
*/
jitter?: boolean;
}
/**
* Service for executing functions with automatic retry logic.
* Supports exponential backoff, max duration, conditional retries, and cancellation.
*/
export class RetryProvider {
protected readonly log = $logger();
protected readonly dateTime = $inject(DateTimeProvider);
/**
* Execute a function with automatic retry logic.
*/
async retry<T extends (...args: any[]) => any>(
options: RetryOptions<T>,
...args: Parameters<T>
): Promise<ReturnType<T>> {
const maxAttempts = options.max ?? 3;
const when = options.when ?? (() => true);
const { handler, onError } = options;
let lastError: Error | undefined;
const startTime = this.dateTime.nowMillis();
const maxDurationMs = options.maxDuration
? this.dateTime.duration(options.maxDuration).asMilliseconds()
: Infinity;
// Combine user-provided signal with additional signal (e.g., app lifecycle)
const signals = [options.signal, options.additionalSignal].filter(Boolean);
const onAbort = () => {
// Always set RetryCancelError when aborted, even if another error exists
// This ensures cancellation takes precedence over retry errors
lastError = new RetryCancelError();
};
// Add abort listeners to all signals
for (const signal of signals) {
signal?.addEventListener("abort", onAbort);
}
// FIX BUG #8: Create combined signal ONCE at the start instead of on each backoff
// This prevents memory leak from creating multiple AbortSignal.any() instances
const waitSignals = [options.signal, options.additionalSignal].filter(
Boolean,
) as AbortSignal[];
const combinedSignal =
waitSignals.length > 0 ? AbortSignal.any(waitSignals) : undefined;
try {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Check for cancellation
if (signals.some((signal) => signal?.aborted)) {
throw new RetryCancelError();
}
// Check for timeout before attempting
if (this.dateTime.nowMillis() - startTime >= maxDurationMs) {
throw new RetryTimeoutError(maxDurationMs);
}
try {
const result = await handler(...args);
// Check for timeout after handler execution
if (this.dateTime.nowMillis() - startTime >= maxDurationMs) {
throw new RetryTimeoutError(maxDurationMs);
}
return result;
} catch (err) {
lastError = err as Error;
// Check for timeout after error
if (this.dateTime.nowMillis() - startTime >= maxDurationMs) {
throw new RetryTimeoutError(maxDurationMs);
}
// Log the error with warning level
this.log.warn("Retry attempt failed", {
attempt,
maxAttempts,
remainingAttempts: maxAttempts - attempt,
error: lastError.message,
errorName: lastError.name,
});
if (!(err instanceof Error) || !when(err)) {
throw err; // don't retry if it's not an Error or `when` returns false
}
// FIX BUG #7: Call onError BEFORE checking if this is the final attempt
// This ensures onError is called for ALL failed attempts, including the last one
if (onError) {
onError(err, attempt, ...args);
}
if (attempt >= maxAttempts) {
break; // will throw lastError after the loop
}
// Calculate and wait for backoff delay
const delay = this.calculateBackoff(attempt, options.backoff);
if (delay > 0) {
await this.dateTime.wait(delay, { signal: combinedSignal });
}
// Check for timeout after backoff wait before next attempt
if (this.dateTime.nowMillis() - startTime >= maxDurationMs) {
throw new RetryTimeoutError(maxDurationMs);
}
}
}
} finally {
// Clean up listeners to prevent memory leaks
for (const signal of signals) {
signal?.removeEventListener("abort", onAbort);
}
}
throw lastError;
}
/**
* Calculate the backoff delay for a given attempt.
*/
protected calculateBackoff(
attempt: number,
options?: number | RetryBackoffOptions,
): number {
if (typeof options === "number") {
return options;
}
const initial = options?.initial ?? 200;
const factor = options?.factor ?? 2;
const max = options?.max ?? 10000;
const useJitter = options?.jitter !== false;
const exponential = initial * factor ** (attempt - 1);
let delay = Math.min(exponential, max);
if (useJitter) {
// Add a random amount of jitter (e.g., up to 50% of the delay)
delay = delay * (1 + Math.random() * 0.5);
}
return Math.floor(delay);
}
}