UNPKG

pi-decider

Version:

Decision backends for pi and omp — TypeSafe Jev, OpenRouter's Decisions API, or an OpenAI-compatible chat proxy — exposed as one typed tool (noul / choice / score)

165 lines (152 loc) • 5.69 kB
/** * HTTP transport shared by both engines: JSON POST/GET with a per-attempt timeout, * bounded retries on 408/429/5xx and network failures, and `retry-after` support. */ import { JevError, messageOf } from "./errors.ts"; const RETRYABLE_STATUS: Record<number, true> = { 408: true, 425: true, 429: true, 500: true, 502: true, 503: true, 504: true, }; const MAX_BACKOFF_MS = 4_000; const BASE_BACKOFF_MS = 400; const MAX_RETRY_AFTER_MS = 30_000; export interface HttpRequestOptions { url: string; method: "GET" | "POST"; headers?: Record<string, string>; body?: unknown; timeoutMs: number; /** Retries after the first attempt; total attempts = maxRetries + 1. */ maxRetries: number; signal?: AbortSignal; } export interface HttpResponse { status: number; headers: Headers; text: string; attempts: number; } export async function requestWithRetry(options: HttpRequestOptions): Promise<HttpResponse> { const payload = options.body === undefined ? undefined : JSON.stringify(options.body); let attempt = 0; let waitMs = 0; for (;;) { if (waitMs > 0) await sleep(waitMs, options.signal); attempt += 1; let response: Response; try { response = await fetchWithTimeout(options, payload); } catch (error) { if (options.signal?.aborted) throw new JevError(`${options.url} request cancelled.`); if (attempt > options.maxRetries) { throw new JevError(`Cannot reach ${options.url}: ${messageOf(error)}`, { cause: error }); } waitMs = backoffMs(attempt); continue; } let text: string; try { text = await response.text(); } catch (error) { throw new JevError(`Failed reading the response from ${options.url}: ${messageOf(error)}`, { status: response.status, }); } if (response.ok) return { status: response.status, headers: response.headers, text, attempts: attempt }; const failure = new JevError(httpErrorMessage(response, text), { status: response.status, body: text }); if (RETRYABLE_STATUS[response.status] !== true || attempt > options.maxRetries) throw failure; waitMs = retryAfterMs(response.headers) ?? backoffMs(attempt); } } export function parseJsonBody(text: string, url: string): unknown { try { return JSON.parse(text) as unknown; } catch (error) { throw new JevError(`${url} returned invalid JSON: ${messageOf(error)} (body: ${snippet(text)})`); } } async function fetchWithTimeout(options: HttpRequestOptions, payload: string | undefined): Promise<Response> { if (options.signal?.aborted) throw new JevError(`${options.url} request cancelled.`); const controller = new AbortController(); let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs); const onAbort = () => controller.abort(); options.signal?.addEventListener("abort", onAbort, { once: true }); try { return await fetch(options.url, { method: options.method, headers: { accept: "application/json", ...(payload === undefined ? {} : { "content-type": "application/json" }), ...options.headers, }, body: payload, signal: controller.signal, }); } catch (error) { if (timedOut) { throw new JevError(`${options.url} timed out after ${options.timeoutMs} ms.`, { cause: error }); } throw error; } finally { clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); } } /** Prefer the provider's own error field over a raw body dump. */ function httpErrorMessage(response: Response, text: string): string { const status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`; let detail: string | undefined; try { const parsed = JSON.parse(text) as unknown; if (parsed !== null && typeof parsed === "object") { const record = parsed as Record<string, unknown>; const error = record.error; if (typeof error === "string") detail = error; else if (error !== null && typeof error === "object") { const inner = (error as Record<string, unknown>).message; if (typeof inner === "string") detail = inner; } if (detail === undefined && typeof record.message === "string") detail = record.message; if (detail === undefined && typeof record.detail === "string") detail = record.detail; } } catch { // Non-JSON error bodies fall through to the raw snippet. } return `${status}: ${detail ?? snippet(text)}`; } function snippet(text: string): string { const collapsed = text.replace(/\s+/g, " ").trim(); return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed; } function retryAfterMs(headers: Headers): number | undefined { const header = headers.get("retry-after"); if (!header) return undefined; const seconds = Number(header.trim()); const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now(); if (!Number.isFinite(ms) || ms <= 0) return undefined; return Math.min(ms, MAX_RETRY_AFTER_MS); } function backoffMs(attempt: number): number { return Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** (attempt - 1)) + Math.floor(Math.random() * 200); } function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> { if (signal?.aborted) return Promise.resolve(); const { promise, resolve } = Promise.withResolvers<void>(); const timer = setTimeout(finish, ms); function finish() { clearTimeout(timer); signal?.removeEventListener("abort", finish); resolve(); } signal?.addEventListener("abort", finish, { once: true }); return promise; }