/** * src/utils/retry.ts * * Generic async retry helper with exponential backoff. * Used throughout the LLM pipeline where network flakiness is expected. */ import { logger } from './logger'; // ─── Types ──────────────────────────────────────────────────────────────────── export interface RetryOptions { /** Maximum number of retry attempts (not counting the initial try). */ maxRetries: number; /** Base delay in milliseconds before first retry. */ delayMs: number; /** Multiplier applied to delay on each subsequent retry. Default: 2 (exponential). */ backoffMultiplier?: number; /** If provided, only retries when this predicate returns true. */ retryOn?: (error: unknown) => boolean; /** Called just before each retry — useful for key rotation side effects. */ onRetry?: (attempt: number, error: unknown) => void; } // ─── Core Retry Function ────────────────────────────────────────────────────── /** * Executes `fn` up to `maxRetries + 1` times total, with exponential backoff. * * @example * const result = await withRetry( * () => callLLM(prompt), * { maxRetries: 3, delayMs: 1000, retryOn: isRateLimitError } * ); */ export async function withRetry( fn: () => Promise, options: RetryOptions ): Promise { const { maxRetries, delayMs, backoffMultiplier = 2, retryOn, onRetry, } = options; let lastError: unknown; for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { try { return await fn(); } catch (err) { lastError = err; // Don't retry if the error type is not eligible if (retryOn && !retryOn(err)) { throw err; } if (attempt > maxRetries) break; // Exhausted retries const delay = delayMs * Math.pow(backoffMultiplier, attempt - 1); onRetry?.(attempt, err); logger.warn( `withRetry: attempt ${attempt}/${maxRetries} failed. Retrying in ${delay}ms.`, { error: err instanceof Error ? err.message : String(err) } ); await sleep(delay); } } throw lastError; } // ─── Rate-Limit Classifier ──────────────────────────────────────────────────── /** * Returns true if the error is a rate-limit response (HTTP 429). * Works with Groq SDK errors, Axios errors, and generic Error messages. */ export function isRateLimitError(err: unknown): boolean { if (err instanceof Error) { const msg = err.message.toLowerCase(); // Covers: "429", "rate limit exceeded", "too many requests", "rate_limit_exceeded" if (msg.includes('429') || msg.includes('rate limit') || msg.includes('too many requests')) { return true; } // Groq SDK wraps status in the error object // eslint-disable-next-line @typescript-eslint/no-explicit-any const status = (err as any).status ?? (err as any).statusCode; if (status === 429) return true; } // Axios error // eslint-disable-next-line @typescript-eslint/no-explicit-any const axiosStatus = (err as any)?.response?.status; if (axiosStatus === 429) return true; return false; } // ─── Private Helpers ────────────────────────────────────────────────────────── function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); }