import { getConfig } from './config.js';
import { logger } from './logger.js';
const TRANSIENT_PATTERNS = new Set([
'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'socket hang up',
'network', 'timeout', 'too many requests', '429', '503', '504',
'RequestTimeout', 'ServiceUnavailable', 'ThrottlingException'
]);
export function isTransientError(error: string | Error): boolean {
const msg = (typeof error === 'string' ? error : error.message).toLowerCase();
for (const pattern of TRANSIENT_PATTERNS) {
if (msg.includes(pattern.toLowerCase())) return true;
}
return false;
}
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
export function calculateBackoff(attempt: number, baseDelay: number): number {
const exp = baseDelay * Math.pow(2, attempt);
const jitter = exp * 0.25 * (Math.random() * 2 - 1);
return Math.min(exp + jitter, 30000);
}
export interface RetryOptions {
maxRetries?: number;
baseDelay?: number;
isRetryable?: (error: Error) => boolean;
onRetry?: (error: Error, attempt: number, delay: number) => void;
}
export async function withRetry<T>(
operation: () => Promise<T>,
name: string,
options: RetryOptions = {}
): Promise<T> {
const config = getConfig();
const maxRetries = options.maxRetries ?? config.maxRetries;
const baseDelay = options.baseDelay ?? config.retryDelayMs;
const isRetryable = options.isRetryable ?? (e => isTransientError(e));
let lastError: Error = new Error(`${name} failed without specific error`);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < maxRetries && isRetryable(lastError)) {
const delay = calculateBackoff(attempt, baseDelay);
logger.warn(`Retrying ${name}`, { attempt: attempt + 1, maxRetries, delayMs: Math.round(delay) });
options.onRetry?.(lastError, attempt + 1, delay);
await sleep(delay);
} else {
break;
}
}
}
logger.error(`${name} failed after ${maxRetries + 1} attempts`, lastError!);
throw lastError!;
}
export const createRetryWrapper = (defaults: RetryOptions = {}) =>
<T>(op: () => Promise<T>, name: string, opts?: RetryOptions) =>
withRetry(op, name, { ...defaults, ...opts });