import { env } from "./config"; import { getAccessToken, refreshTokens } from "./auth"; export { sessionMemory } from "./session"; import { sessionMemory } from "./session"; export type ApiErrorEnvelope = { error: { code: string; message: string; request_id?: string; details?: Record }; }; export class ApiError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly requestId?: string, readonly retryAfter?: number, ) { super(message); this.name = "ApiError"; } } export type Diagnostic = { at: number; method: string; path: string; status: number; requestId: string; }; const diagnostics: Diagnostic[] = []; export const getDiagnostics = () => [...diagnostics]; function traceparent() { const traceId = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "").slice(0, 16); const spanId = crypto.randomUUID().replaceAll("-", "").slice(0, 16); return `00-${traceId.slice(0, 32)}-${spanId}-01`; } function safePath(path: string) { return path.split("?")[0] ?? path; } async function parseError(response: Response, requestId: string) { let envelope: ApiErrorEnvelope | undefined; try { envelope = (await response.json()) as ApiErrorEnvelope; } catch { /* intentionally empty */ } const code = envelope?.error?.code ?? `http_${response.status}`; const retry = Number(response.headers.get("Retry-After")); return new ApiError( response.status, code, envelope?.error?.message ?? "Запрос не выполнен", envelope?.error?.request_id ?? requestId, Number.isFinite(retry) ? retry : undefined, ); } export async function apiRequest( path: string, init: RequestInit & { protected?: boolean } = {}, replayed = false, ): Promise { const requestId = crypto.randomUUID(); const isProtected = init.protected ?? false; const headers = new Headers(init.headers); headers.set("Accept", "application/json"); headers.set("X-Request-ID", requestId); headers.set("traceparent", traceparent()); if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); if (isProtected) { const token = getAccessToken(); if (!token) throw new ApiError(401, "unauthorized", "Требуется авторизация", requestId); headers.set("Authorization", `Bearer ${token}`); if (sessionMemory.id) headers.set("X-Ux-Session-Id", sessionMemory.id); } const response = await fetch(`${env.apiBaseUrl}${path}`, { ...init, headers }); diagnostics.unshift({ at: Date.now(), method: init.method ?? "GET", path: safePath(path), status: response.status, requestId: response.headers.get("X-Request-ID") ?? requestId, }); diagnostics.splice(20); if (response.status === 401 && isProtected && !replayed) { await refreshTokens(); return apiRequest(path, init, true); } if (!response.ok) throw await parseError(response, requestId); if (response.status === 204) return undefined as T; return response.json() as Promise; } export const json = (value: unknown) => JSON.stringify(value); export const idempotencyHeaders = (key: string) => ({ "Idempotency-Key": key });