titan-server / src /utils /retry.ts
M-hv1's picture
Upload 3 files
7fd62bd verified
Raw
History Blame Contribute Delete
3.74 kB
/**
* 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<T>(
fn: () => Promise<T>,
options: RetryOptions
): Promise<T> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}