UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

236 lines (198 loc) 6.82 kB
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); } }