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
text/typescript
/**
* 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;
}