diff --git a/src/lib/api.ts b/src/lib/api.ts index 457a93e..8e358c6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -15,6 +15,52 @@ const API_V1 = `${API_BASE}/api/v1`; const SEED_KEY = "nrxp_seed"; const TOKEN_KEY = "nrxp_token"; +// --- Ретраи с backoff для сетевых сбоев --- +// Раньше любой fetch() к бэкенду падал с первой же сетевой ошибки/5xx без +// единой попытки повторить — временная просадка сети/перезапуск бэкенда сразу +// приводили к нечитаемой ошибке в UI ("anon_failed" и т.п.). Ретраим только +// сетевые сбои (fetch throws) и 5xx (backend сам сломан) — 4xx-ответы (401 на +// просроченный код, 410 и т.д.) НЕ ретраим, это осмысленный ответ сервера, +// а не транзиентный сбой, повторять его бессмысленно. +const RETRY_ATTEMPTS = 2; // + первая попытка = до 3 всего +const RETRY_BASE_DELAY_MS = 400; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class NetworkUnavailableError extends Error { + constructor(cause?: unknown) { + super("NETWORK_UNAVAILABLE"); + this.name = "NetworkUnavailableError"; + if (cause) this.cause = cause; + } +} + +async function fetchWithRetry( + input: string, + init?: RequestInit, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= RETRY_ATTEMPTS; attempt++) { + try { + const res = await fetch(input, init); + if (res.status >= 500 && attempt < RETRY_ATTEMPTS) { + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + continue; + } + return res; + } catch (e) { + lastError = e; + if (attempt < RETRY_ATTEMPTS) { + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + continue; + } + } + } + throw new NetworkUnavailableError(lastError); +} + // --- Доменная модель сервера (камелкейс-зеркало VpnNode из бэкенда) --- export interface ServerNode { id: string; @@ -89,7 +135,7 @@ function isTokenValid(token: string | null): token is string { } async function generateSeed(): Promise { - const res = await fetch(`${API_V1}/auth/seed/generate`, { + const res = await fetchWithRetry(`${API_V1}/auth/seed/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, }); @@ -101,7 +147,7 @@ async function generateSeed(): Promise { } async function loginSeed(seed: string): Promise { - const res = await fetch(`${API_V1}/auth/seed/login`, { + const res = await fetchWithRetry(`${API_V1}/auth/seed/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ seed }), @@ -133,7 +179,7 @@ async function ensureToken(): Promise { export async function fetchNodes(): Promise { const token = await ensureToken(); - const res = await fetch(`${API_V1}/nodes`, { + const res = await fetchWithRetry(`${API_V1}/nodes`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`GET /nodes failed: ${res.status}`); @@ -172,7 +218,7 @@ export async function loginWithSeed(seed: string): Promise { * поэтому это одноразовый обмен, а не поллинг: `true` при первом же успешном вызове. */ export async function pollTelegramLogin(authCode: string): Promise { - const res = await fetch(`${API_V1}/auth/telegram/session/${authCode}`); + const res = await fetchWithRetry(`${API_V1}/auth/telegram/session/${authCode}`); if (res.status === 401 || res.status === 410) { throw new Error("TELEGRAM_LOGIN_EXPIRED"); } @@ -194,7 +240,7 @@ export interface UsageInfo { /** Текущий расход/лимит трафика юзера — для индикатора на `VpnStats`. */ export async function fetchUsage(): Promise { const token = await ensureToken(); - const res = await fetch(`${API_V1}/users/me`, { + const res = await fetchWithRetry(`${API_V1}/users/me`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`GET /users/me failed: ${res.status}`); @@ -212,7 +258,7 @@ let cachedBotUsername: string | null = null; export async function getBotUsername(): Promise { if (cachedBotUsername) return cachedBotUsername; try { - const res = await fetch(`${API_V1}/config`); + const res = await fetchWithRetry(`${API_V1}/config`); if (!res.ok) return null; const data = (await res.json()) as { bot_username: string }; cachedBotUsername = data.bot_username;