From 0dd4869cbbfbe13a31f425d1c8929303ad5db95c Mon Sep 17 00:00:00 2001 From: nineap Date: Thu, 9 Jul 2026 16:36:39 +0700 Subject: [PATCH] =?UTF-8?q?api.ts:=20retry=20=D1=81=20=D1=8D=D0=BA=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=86=D0=B8=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=20backoff=20=D0=B4=D0=BB=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B5=D1=82=D0=B5=D0=B2=D1=8B=D1=85=20=D1=81=D0=B1=D0=BE=D0=B5?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Различает настоящие сетевые сбои/5xx (ретраить) от осмысленных 4xx-ответов приложения (не ретраить) — раньше единичный сетевой глитч ронял весь флоу без единой попытки повтора. Co-Authored-By: Claude Sonnet 5 --- src/lib/api.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 6 deletions(-) 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;