i18n-ai-translate
Version:
AI-powered localization CLI, Node library, and GitHub Action. Translate i18next JSON, Gettext PO, Java .properties, and iOS .strings with ChatGPT, Claude, Gemini, or local Ollama models.
100 lines (84 loc) • 3.14 kB
text/typescript
import { delay, printWarn } from "./utils";
import type RateLimiter from "./rate_limiter";
export type RetryWithBackoffOptions = {
maxRetries: number;
baseDelayMs?: number;
maxDelayMs?: number;
rateLimiter?: RateLimiter;
verbose?: boolean;
};
const DEFAULT_BASE_DELAY_MS = 1_000;
const DEFAULT_MAX_DELAY_MS = 60_000;
/** Detects provider rate-limit errors across OpenAI, Anthropic, and Gemini shapes. */
export function isRateLimitError(err: unknown): boolean {
if (err === null || typeof err !== "object") return false;
const anyErr = err as { status?: number; message?: string };
if (anyErr.status === 429) return true;
if (typeof anyErr.message === "string") {
if (/\b429\b/.test(anyErr.message)) return true;
if (/rate[ _-]?limit/i.test(anyErr.message)) return true;
if (/RESOURCE_EXHAUSTED/.test(anyErr.message)) return true;
}
return false;
}
/** Reads a Retry-After header (seconds or HTTP-date) off an SDK error. */
export function extractRetryAfterMs(err: unknown): number | null {
if (err === null || typeof err !== "object") return null;
const headers = (err as { headers?: Record<string, string> }).headers;
if (!headers) return null;
const raw = headers["retry-after"] ?? headers["Retry-After"];
if (!raw) return null;
const seconds = Number(raw);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const asDate = Date.parse(raw);
if (Number.isFinite(asDate)) return Math.max(0, asDate - Date.now());
return null;
}
function computeBackoffMs(
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
): number {
const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
return Math.floor(Math.random() * exponential);
}
/** Retries `job` with full-jitter exponential backoff; penalizes the shared limiter on 429s. */
export async function retryWithBackoff<T>(
job: () => Promise<T>,
options: RetryWithBackoffOptions,
): Promise<T> {
const {
maxRetries,
baseDelayMs = DEFAULT_BASE_DELAY_MS,
maxDelayMs = DEFAULT_MAX_DELAY_MS,
rateLimiter,
verbose,
} = options;
let attempt = 0;
while (true) {
try {
return await job();
} catch (err) {
if (attempt >= maxRetries) throw err;
const rateLimited = isRateLimitError(err);
const retryAfter = extractRetryAfterMs(err);
const computed = computeBackoffMs(attempt, baseDelayMs, maxDelayMs);
const waitMs = Math.min(
maxDelayMs,
retryAfter !== null ? retryAfter : computed,
);
if (rateLimited && rateLimiter) {
rateLimiter.penalize(waitMs);
}
if (verbose) {
printWarn(
`Retry ${attempt + 1}/${maxRetries} after ${waitMs}ms (${
rateLimited ? "rate-limited" : "error"
}): ${err}`,
);
}
await delay(waitMs);
attempt++;
}
}
}