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; 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 { 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 { 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 { 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(url: string, init: RequestInit = {}, options: ApiFetchOptions = {}): Promise { 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( url: string, body: unknown, init: RequestInit = {}, options: ApiFetchOptions = {} ): Promise { 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( url: string, form: FormData, init: RequestInit = {}, options: ApiFetchOptions = {} ): Promise { 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 { 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 { 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 { 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(); }