107 lines
2.8 KiB
TypeScript
107 lines
2.8 KiB
TypeScript
import { ApiError, toApiError } from "./errors";
|
||
|
||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "http://localhost:3001";
|
||
|
||
function buildUrl(path: string): string {
|
||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||
return `${API_BASE_URL}${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]);
|
||
|
||
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 && GATEWAY_RETRY_STATUSES.has(response.status) && attempt < maxAttempts;
|
||
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: "Не удалось связаться с сервером.",
|
||
});
|
||
}
|