o2o-infinith-demo/supabase/functions/_shared/retry.ts

225 lines
6.3 KiB
TypeScript

/**
* Retry utility for external API calls.
*
* Features:
* - Exponential backoff with jitter
* - Respects Retry-After header (429)
* - Permanent failure detection (400/401/403/404 → no retry)
* - Per-request timeout via AbortController
* - Domain-level rate limiting (e.g., Firecrawl 500ms gap)
*/
// ─── Types ───
export interface RetryOptions {
/** Max number of retries (default: 2) */
maxRetries?: number;
/** Backoff delays in ms per attempt (default: [1000, 3000]) */
backoffMs?: number[];
/** HTTP status codes to retry on (default: [429, 500, 502, 503]) */
retryOn?: number[];
/** Per-request timeout in ms (default: 45000) */
timeoutMs?: number;
/** Label for logging */
label?: string;
}
interface RetryResult {
response: Response;
attempts: number;
retried: boolean;
}
// ─── Domain Rate Limiter ───
const domainLastCall = new Map<string, number>();
const DOMAIN_INTERVALS: Record<string, number> = {
"api.firecrawl.dev": 500,
"api.perplexity.ai": 200,
};
function getDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return "";
}
}
async function waitForDomainSlot(url: string): Promise<void> {
const domain = getDomain(url);
const interval = DOMAIN_INTERVALS[domain];
if (!interval) return;
const last = domainLastCall.get(domain) || 0;
const elapsed = Date.now() - last;
if (elapsed < interval) {
await new Promise((r) => setTimeout(r, interval - elapsed));
}
domainLastCall.set(domain, Date.now());
}
// ─── Permanent failure codes (never retry) ───
const PERMANENT_FAILURES = new Set([400, 401, 403, 404, 405, 409, 422]);
// ─── Main Function ───
/**
* fetch() with automatic retry, timeout, and rate limiting.
*
* @example
* const res = await fetchWithRetry("https://api.example.com/data", {
* method: "POST",
* headers: { "Content-Type": "application/json" },
* body: JSON.stringify({ query: "test" }),
* }, { maxRetries: 2, timeoutMs: 30000, label: "example-api" });
*/
export async function fetchWithRetry(
url: string,
init?: RequestInit,
opts?: RetryOptions,
): Promise<Response> {
const {
maxRetries = 2,
backoffMs = [1000, 3000],
retryOn = [429, 500, 502, 503],
timeoutMs = 45000,
label = getDomain(url),
} = opts || {};
let lastError: Error | null = null;
const retrySet = new Set(retryOn);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// Rate limit between domain calls
await waitForDomainSlot(url);
// AbortController for per-request timeout
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
clearTimeout(timer);
// Success
if (response.ok) return response;
// Permanent failure — don't retry
if (PERMANENT_FAILURES.has(response.status)) {
console.warn(
`[retry:${label}] Permanent failure ${response.status} on attempt ${attempt + 1}`,
);
return response;
}
// Retryable failure
if (retrySet.has(response.status) && attempt < maxRetries) {
let delay = backoffMs[attempt] || backoffMs[backoffMs.length - 1] || 3000;
// Respect Retry-After header for 429
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
const parsed = parseInt(retryAfter, 10);
if (!isNaN(parsed)) {
delay = Math.max(delay, parsed * 1000);
}
}
}
// Add jitter (±20%)
delay = delay * (0.8 + Math.random() * 0.4);
console.warn(
`[retry:${label}] Status ${response.status}, retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries + 1})`,
);
await new Promise((r) => setTimeout(r, delay));
continue;
}
// Non-retryable or exhausted retries
return response;
} catch (err) {
clearTimeout(timer);
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < maxRetries) {
const delay = (backoffMs[attempt] || 3000) * (0.8 + Math.random() * 0.4);
console.warn(
`[retry:${label}] Network error: ${lastError.message}, retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries + 1})`,
);
await new Promise((r) => setTimeout(r, delay));
continue;
}
}
}
throw lastError || new Error(`[retry:${label}] All ${maxRetries + 1} attempts failed`);
}
// ─── Convenience: JSON fetch with retry ───
export async function fetchJsonWithRetry<T = unknown>(
url: string,
init?: RequestInit,
opts?: RetryOptions,
): Promise<{ data: T | null; status: number; error?: string }> {
try {
const res = await fetchWithRetry(url, init, opts);
if (!res.ok) {
const text = await res.text().catch(() => "");
return { data: null, status: res.status, error: `HTTP ${res.status}: ${text.slice(0, 200)}` };
}
const data = (await res.json()) as T;
return { data, status: res.status };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { data: null, status: 0, error: msg };
}
}
// ─── Channel Task Wrapper ───
export interface ChannelTaskResult {
channel: string;
success: boolean;
error?: string;
httpStatus?: number;
durationMs: number;
}
/**
* Wraps a channel collection task with timing and error capture.
* Used by collect-channel-data to track per-channel success/failure.
*
* @example
* const [result, taskMeta] = await wrapChannelTask("instagram", async () => {
* // ... collect instagram data ...
* channelData.instagram = data;
* });
*/
export async function wrapChannelTask(
channel: string,
task: () => Promise<void>,
): Promise<ChannelTaskResult> {
const start = Date.now();
try {
await task();
return { channel, success: true, durationMs: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[channel:${channel}] Error: ${msg}`);
return {
channel,
success: false,
error: msg,
durationMs: Date.now() - start,
};
}
}