/** * fetchWithRetry — transient-failure absorption for the pi providers. * * The Claude SDK retries transient provider errors inside its subprocess and * codex suppresses willRetry errors; pi's hand-rolled providers previously did * exactly one fetch, so a single 429 (routine on Gemini free tier) or a 5xx * blip killed an entire multi-minute agentic turn. This wraps the initial * request only — once a stream is open, mid-stream failures are handled by the * session's round-retry (a full-history resend is stateless, so re-running a * round that produced nothing is safe). * * Policy: up to 3 attempts on network errors and HTTP 408/429/5xx, exponential * backoff 1s/2s with jitter, honoring Retry-After when it's short. A long * Retry-After (> 15s) means the provider really wants us to back off — return * the response and let the classifier surface a friendly rate-limit message. */ const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504, 529]); const MAX_ATTEMPTS = 3; const MAX_HONORED_RETRY_AFTER_MS = 15_000; function retryAfterMs(res: Response): number | undefined { const h = res.headers.get('retry-after'); if (!h) return undefined; const secs = Number(h); if (Number.isFinite(secs)) return Math.max(0, secs * 1000); const date = Date.parse(h); if (!Number.isNaN(date)) return Math.max(0, date - Date.now()); return undefined; } function abortError(): Error { const err = new Error('This operation was aborted'); err.name = 'AbortError'; return err; } /** Sleep that wakes immediately (and throws AbortError) when the signal fires. */ export function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(abortError()); const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); const onAbort = () => { clearTimeout(timer); reject(abortError()); }; signal?.addEventListener('abort', onAbort, { once: true }); }); } /** Per-chunk SSE idle guard (audit D6-7). Without it, a stalled-but-open * stream waits ~300s for Node's undici body timeout and then surfaces a * cryptic 'terminated'. 120s is generous: Anthropic pings every ~20s and * Gemini/OpenAI chunk every few seconds while healthy. */ export const SSE_IDLE_TIMEOUT_MS = 120_000; export async function readWithIdleTimeout( reader: { read(): Promise; cancel?: (reason?: any) => Promise | void }, providerLabel: string, ): Promise { let timer: NodeJS.Timeout | undefined; const timeoutP = new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`${providerLabel} stream stalled — no data received for ${SSE_IDLE_TIMEOUT_MS / 1000}s.`)), SSE_IDLE_TIMEOUT_MS, ); }); const readP = reader.read(); // Mark the losing read promise handled so a post-timeout rejection (after // reader.cancel) never surfaces as an unhandledRejection. readP.catch?.(() => {}); try { return await Promise.race([readP, timeoutP]); } catch (err) { try { void reader.cancel?.(); } catch {} throw err; } finally { clearTimeout(timer!); } } export async function fetchWithRetry( url: string, init: RequestInit & { signal?: AbortSignal }, ): Promise { let lastErr: any; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { if (init.signal?.aborted) throw abortError(); let res: Response; try { res = await fetch(url, init); } catch (err: any) { if (err?.name === 'AbortError') throw err; lastErr = err; if (attempt === MAX_ATTEMPTS - 1) throw err; await sleep(1000 * 2 ** attempt + Math.random() * 250, init.signal); continue; } if (res.ok || !RETRYABLE_STATUS.has(res.status) || attempt === MAX_ATTEMPTS - 1) { return res; } const hinted = retryAfterMs(res); if (hinted !== undefined && hinted > MAX_HONORED_RETRY_AFTER_MS) { return res; // provider asked for a long back-off — surface it instead of stalling the turn } // Drain/cancel the body so the connection can be reused before retrying. try { await res.body?.cancel(); } catch {} await sleep(hinted ?? 1000 * 2 ** attempt + Math.random() * 250, init.signal); } // Unreachable, but keeps TS happy. throw lastErr ?? new Error('fetchWithRetry: exhausted attempts'); }