import { isFatalHttpError, isTransientHttpError, type RetryOptions, retryWithBackoff, } from "./retry.ts"; export type ResilientSource = "primary" | "fallback" | "cache-fresh" | "cache-stale"; export interface ResilientResult { value: T; source: ResilientSource; /** Age in ms since the cache entry was created (only set for cache-stale). */ staleAgeMs?: number; /** Errors collected from the primary/fallback tiers (in order). */ errors: Error[]; } export interface ResilientCache { get(): T | null; getStale(): { value: T; createdAt: number } | null; put(value: T): void; } export interface ResilientTiers { primary: () => Promise; /** Optional secondary scraper (e.g., Playwright fallback for Apple Akamai blocks). */ fallback?: () => Promise; cache: ResilientCache; } export interface ResilientOptions { retry?: RetryOptions; logger?: (level: "info" | "warn" | "error", msg: string, ctx?: Record) => void; clock?: () => number; /** * Maximum age (ms) of a stale cache entry that is still considered acceptable * as a fallback. Older entries are treated as if no cache existed. * Default: undefined (no cap — any stale entry is served). */ maxStaleMs?: number; } const noopLogger: NonNullable = () => {}; /** * 3-tier resilience pattern: cache (fresh) → primary lib → fallback → cache (stale). * * - Returns immediately if the cache has a fresh entry. * - Otherwise calls primary with exponential backoff. Caches on success. * - If primary exhausts retries, calls fallback (if provided). Caches on success. * - If both fail and a stale cache exists, returns it with a warning. * - If everything fails, throws the last collected error. */ export async function resilient( tiers: ResilientTiers, opts: ResilientOptions = {}, ): Promise> { const log = opts.logger ?? noopLogger; const clock = opts.clock ?? Date.now; const errors: Error[] = []; const cached = tiers.cache.get(); if (cached !== null) { return { value: cached, source: "cache-fresh", errors }; } try { const value = await retryWithBackoff(tiers.primary, { ...opts.retry, shouldRetry: (e, attempt) => { if (isFatalHttpError(e)) return false; if (opts.retry?.shouldRetry) return opts.retry.shouldRetry(e, attempt); return isTransientHttpError(e); }, }); tiers.cache.put(value); return { value, source: "primary", errors }; } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); errors.push(err); log("warn", "primary tier failed", { error: err.message }); } if (tiers.fallback) { try { const value = await tiers.fallback(); tiers.cache.put(value); return { value, source: "fallback", errors }; } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); errors.push(err); log("warn", "fallback tier failed", { error: err.message }); } } const stale = tiers.cache.getStale(); if (stale) { const ageMs = clock() - stale.createdAt; if (opts.maxStaleMs !== undefined && ageMs > opts.maxStaleMs) { log("warn", "stale cache rejected (over maxStaleMs)", { staleAgeMs: ageMs, maxStaleMs: opts.maxStaleMs, }); } else { log("warn", "all tiers failed; serving stale cache", { staleAgeMs: ageMs, errors: errors.map((e) => e.message), }); return { value: stale.value, source: "cache-stale", staleAgeMs: ageMs, errors, }; } } const last = errors[errors.length - 1] ?? new Error("resilient: all tiers failed"); throw last; }