/**
* Retry handler with exponential backoff and jitter
*
* Provides resilient execution of async operations with configurable
* retry policies, exponential backoff, and jitter to prevent thundering herd.
*/
import { createLogger } from './logger.js';
const logger = createLogger('retry');
/**
* Retry configuration options
*/
export interface RetryOptions {
/** Maximum number of retry attempts (default: 3) */
maxAttempts?: number;
/** Initial delay in milliseconds (default: 1000) */
initialDelayMs?: number;
/** Maximum delay in milliseconds (default: 30000) */
maxDelayMs?: number;
/** Backoff multiplier (default: 2) */
backoffMultiplier?: number;
/** Add random jitter to prevent thundering herd (default: true) */
jitter?: boolean;
/** Jitter factor 0-1, portion of delay to randomize (default: 0.25) */
jitterFactor?: number;
/** Function to determine if error is retryable (default: all errors) */
isRetryable?: (error: unknown) => boolean;
/** Callback on each retry attempt */
onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
}
const DEFAULT_OPTIONS: Required<RetryOptions> = {
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2,
jitter: true,
jitterFactor: 0.25,
isRetryable: () => true,
onRetry: () => undefined,
};
/**
* Calculate delay with exponential backoff and optional jitter
*/
function calculateDelay(
attempt: number,
options: Required<RetryOptions>
): number {
// Exponential backoff: initialDelay * multiplier^attempt
const exponentialDelay =
options.initialDelayMs * Math.pow(options.backoffMultiplier, attempt);
// Cap at max delay
const cappedDelay = Math.min(exponentialDelay, options.maxDelayMs);
// Add jitter if enabled
if (options.jitter) {
const jitterRange = cappedDelay * options.jitterFactor;
const jitter = Math.random() * jitterRange * 2 - jitterRange;
return Math.max(0, Math.round(cappedDelay + jitter));
}
return Math.round(cappedDelay);
}
/**
* Sleep for specified milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Execute an async function with retry logic
*
* @example
* ```typescript
* const result = await withRetry(
* () => fetchExternalApi(),
* {
* maxAttempts: 5,
* isRetryable: (err) => err instanceof NetworkError,
* onRetry: (err, attempt) => logger.warn('Retrying', { attempt }),
* }
* );
* ```
*/
export async function withRetry<T>(
fn: () => Promise<T>,
options?: RetryOptions
): Promise<T> {
const opts: Required<RetryOptions> = { ...DEFAULT_OPTIONS, ...options };
let lastError: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
// Check if we should retry
const isLastAttempt = attempt === opts.maxAttempts - 1;
const shouldRetry = !isLastAttempt && opts.isRetryable(error);
if (!shouldRetry) {
throw error;
}
// Calculate delay and wait
const delayMs = calculateDelay(attempt, opts);
logger.debug('Retry scheduled', {
attempt: attempt + 1,
maxAttempts: opts.maxAttempts,
delayMs,
error: error instanceof Error ? error.message : String(error),
});
opts.onRetry(error, attempt + 1, delayMs);
await sleep(delayMs);
}
}
// Should not reach here, but TypeScript needs this
throw lastError;
}
/**
* Create a retry wrapper with preset options
*
* @example
* ```typescript
* const apiRetry = createRetryHandler({
* maxAttempts: 5,
* isRetryable: isNetworkError,
* });
*
* const result = await apiRetry(() => callApi());
* ```
*/
export function createRetryHandler(
defaultOptions: RetryOptions
): <T>(fn: () => Promise<T>, options?: RetryOptions) => Promise<T> {
return <T>(fn: () => Promise<T>, options?: RetryOptions) =>
withRetry(fn, { ...defaultOptions, ...options });
}
/**
* Common retryable error checkers
*/
export const RetryableErrors = {
/** Network/connection errors */
isNetworkError: (error: unknown): boolean => {
if (error instanceof Error) {
const networkCodes = ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND'];
return networkCodes.some((code) => error.message.includes(code));
}
return false;
},
/** HTTP 5xx server errors */
isServerError: (error: unknown): boolean => {
if (error instanceof Error && 'status' in error) {
const status = (error as { status: number }).status;
return status >= 500 && status < 600;
}
return false;
},
/** HTTP 429 rate limit errors */
isRateLimitError: (error: unknown): boolean => {
if (error instanceof Error && 'status' in error) {
return (error as { status: number }).status === 429;
}
return false;
},
/** Combined: network, server, or rate limit */
isTransientError: (error: unknown): boolean => {
return (
RetryableErrors.isNetworkError(error) ||
RetryableErrors.isServerError(error) ||
RetryableErrors.isRateLimitError(error)
);
},
};