Files
runners-calendar/frontend/src/api/http.ts
Anton 8eaf006906
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
refactor(api): unify /api contract across frontend, nginx, and backend
2026-04-08 11:59:46 +03:00

115 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<unknown> {
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<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
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<string, string> = {};
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: "Не удалось связаться с сервером.",
});
}