| 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 { |
| |
| } |
| 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(); |
| } |
|
|