Pete Dunn
Harden unified KB control flow, telemetry, and 20-case antenna eval runner
8f68f10
Raw
History Blame Contribute Delete
7.03 kB
import { extractErrorMessage, parseJsonOrText } from "../utils/http";
const DEFAULT_TIMEOUT_MS = 20000;
const DEFAULT_RETRY_DELAY_MS = 250;
export class ApiError extends Error {
status: number;
requestId?: string;
data?: unknown;
url: string;
constructor(message: string, params: { status: number; requestId?: string; data?: unknown; url: string }) {
super(message);
this.name = "ApiError";
this.status = params.status;
this.requestId = params.requestId;
this.data = params.data;
this.url = params.url;
}
}
export type ApiFetchOptions = {
timeoutMs?: number;
retries?: number;
retryDelayMs?: number;
retryUnsafe?: boolean;
};
type AccessTokenProvider = () => Promise<string | null>;
let accessTokenProvider: AccessTokenProvider | null = null;
export function setApiAccessTokenProvider(provider: AccessTokenProvider | null): void {
accessTokenProvider = provider;
}
function createRequestId(): string {
try {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
} catch {
// ignore
}
const rnd = Math.random().toString(16).slice(2);
return `req_${Date.now().toString(36)}_${rnd}`;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function shouldRetryStatus(status: number): boolean {
return status === 429 || status >= 500;
}
function isSafeMethod(method: string): boolean {
return method === "GET" || method === "HEAD" || method === "OPTIONS";
}
function withTimeoutSignal(
signal: AbortSignal | null | undefined,
timeoutMs: number
): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const onAbort = () => controller.abort();
if (signal) {
if (signal.aborted) controller.abort();
else signal.addEventListener("abort", onAbort, { once: true });
}
const timer = window.setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
return {
signal: controller.signal,
cleanup: () => {
window.clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort);
},
};
}
export async function apiFetch(input: string, init: RequestInit = {}, options: ApiFetchOptions = {}): Promise<Response> {
const method = String(init.method || "GET").toUpperCase();
const retryUnsafe = Boolean(options.retryUnsafe);
const safe = isSafeMethod(method) || retryUnsafe;
const retries = Math.max(0, options.retries ?? (safe ? 2 : 0));
const retryDelayMs = Math.max(25, options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS);
const timeoutMs = Math.max(1000, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
let attempt = 0;
while (true) {
const { signal, cleanup } = withTimeoutSignal(init.signal, timeoutMs);
try {
const headers = new Headers(init.headers || {});
if (!headers.has("X-Request-ID")) {
headers.set("X-Request-ID", createRequestId());
}
if (accessTokenProvider && !headers.has("Authorization")) {
try {
const token = await accessTokenProvider();
if (token) headers.set("Authorization", `Bearer ${token}`);
} catch (err: any) {
const reason = String(err?.message || "").trim();
const suffix = reason ? ` ${reason}` : "";
throw new Error(`Unable to acquire access token.${suffix}`.trim());
}
}
const response = await fetch(input, { ...init, method, signal, headers });
cleanup();
if (attempt < retries && safe && shouldRetryStatus(response.status)) {
attempt += 1;
await sleep(retryDelayMs * attempt);
continue;
}
return response;
} catch (err) {
cleanup();
if (attempt < retries && safe) {
attempt += 1;
await sleep(retryDelayMs * attempt);
continue;
}
throw err;
}
}
}
async function toApiError(response: Response, url: string): Promise<ApiError> {
const parsed = await parseJsonOrText(response);
const message = extractErrorMessage(parsed, `${response.status} ${response.statusText || "Request failed"}`);
return new ApiError(message, {
status: response.status,
requestId: response.headers.get("x-request-id") || undefined,
data: parsed.data ?? parsed.text,
url,
});
}
export async function apiGetJson<T>(url: string, init: RequestInit = {}, options: ApiFetchOptions = {}): Promise<T> {
const response = await apiFetch(url, { ...init, method: "GET" }, options);
if (!response.ok) throw await toApiError(response, url);
return (await response.json()) as T;
}
export async function apiPostJson<T>(
url: string,
body: unknown,
init: RequestInit = {},
options: ApiFetchOptions = {}
): Promise<T> {
const headers = new Headers(init.headers || {});
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await apiFetch(
url,
{
...init,
method: "POST",
headers,
body: typeof body === "string" ? body : JSON.stringify(body),
},
options
);
if (!response.ok) throw await toApiError(response, url);
return (await response.json()) as T;
}
export async function apiPostFormJson<T>(
url: string,
form: FormData,
init: RequestInit = {},
options: ApiFetchOptions = {}
): Promise<T> {
const response = await apiFetch(url, { ...init, method: "POST", body: form }, options);
if (!response.ok) throw await toApiError(response, url);
return (await response.json()) as T;
}
export async function apiPostJsonBlob(
url: string,
body: unknown,
init: RequestInit = {},
options: ApiFetchOptions = {}
): Promise<Blob> {
const headers = new Headers(init.headers || {});
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await apiFetch(
url,
{
...init,
method: "POST",
headers,
body: typeof body === "string" ? body : JSON.stringify(body),
},
options
);
if (!response.ok) throw await toApiError(response, url);
return await response.blob();
}
export async function apiPostFormBlob(
url: string,
form: FormData,
init: RequestInit = {},
options: ApiFetchOptions = {}
): Promise<Blob> {
const response = await apiFetch(url, { ...init, method: "POST", body: form }, options);
if (!response.ok) throw await toApiError(response, url);
return await response.blob();
}
export async function apiPostJsonText(
url: string,
body: unknown,
init: RequestInit = {},
options: ApiFetchOptions = {}
): Promise<string> {
const headers = new Headers(init.headers || {});
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await apiFetch(
url,
{
...init,
method: "POST",
headers,
body: typeof body === "string" ? body : JSON.stringify(body),
},
options
);
if (!response.ok) throw await toApiError(response, url);
return await response.text();
}