import { ApiError, isStructuredApiErrorPayload, toApiError } from "./errors"; const API_ROOT = "/api"; function buildUrl(path: string): string { const normalizedPath = path.startsWith("/") ? path : `/${path}`; return `${API_ROOT}${normalizedPath}`; } async function parseResponseBody(response: Response): Promise { const text = await response.text(); if (!text.trim()) { return null; } const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { try { return JSON.parse(text) as unknown; } catch { return null; } } const trimmed = text.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) { try { return JSON.parse(text) as unknown; } catch { return null; } } return null; } const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]); /** Повтор при «пустом» 404: часто бывает при нескольких инстансах/прокси до полного деплоя. */ function shouldRetryIdempotentError(status: number, payload: unknown): boolean { if (GATEWAY_RETRY_STATUSES.has(status)) { return true; } return status === 404 && !isStructuredApiErrorPayload(payload); } function delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } export async function requestJson(path: string, init?: RequestInit): Promise { const method = (init?.method ?? "GET").toUpperCase(); const idempotent = method === "GET" || method === "HEAD"; const maxAttempts = idempotent ? 3 : 1; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { const defaultHeaders: Record = {}; if (method !== "GET" && method !== "HEAD") { defaultHeaders["Content-Type"] = "application/json"; } const response = await fetch(buildUrl(path), { ...init, headers: { ...defaultHeaders, ...(init?.headers ?? {}), }, }); if (response.status === 204) { return undefined as T; } const payload = await parseResponseBody(response); if (!response.ok) { const retryable = idempotent && attempt < maxAttempts && shouldRetryIdempotentError(response.status, payload); if (retryable) { await delay(80 * attempt); continue; } throw toApiError(response.status, payload); } return payload as T; } catch (error) { if (error instanceof ApiError) { throw error; } if (error instanceof DOMException && error.name === "AbortError") { throw error; } const retryable = idempotent && attempt < maxAttempts; if (retryable) { await delay(80 * attempt); continue; } throw new ApiError({ code: "network_error", status: null, message: "Не удалось связаться с сервером.", }); } } throw new ApiError({ code: "network_error", status: null, message: "Не удалось связаться с сервером.", }); }