api.ts: retry с экспоненциальным backoff для сетевых сбоев

Различает настоящие сетевые сбои/5xx (ретраить) от осмысленных 4xx-ответов
приложения (не ретраить) — раньше единичный сетевой глитч ронял весь флоу
без единой попытки повтора.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 16:36:39 +07:00
parent b8e1e925b3
commit 0dd4869cbb
+52 -6
View File
@@ -15,6 +15,52 @@ const API_V1 = `${API_BASE}/api/v1`;
const SEED_KEY = "nrxp_seed"; const SEED_KEY = "nrxp_seed";
const TOKEN_KEY = "nrxp_token"; 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<void> {
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<Response> {
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 из бэкенда) --- // --- Доменная модель сервера (камелкейс-зеркало VpnNode из бэкенда) ---
export interface ServerNode { export interface ServerNode {
id: string; id: string;
@@ -89,7 +135,7 @@ function isTokenValid(token: string | null): token is string {
} }
async function generateSeed(): Promise<string> { async function generateSeed(): Promise<string> {
const res = await fetch(`${API_V1}/auth/seed/generate`, { const res = await fetchWithRetry(`${API_V1}/auth/seed/generate`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
@@ -101,7 +147,7 @@ async function generateSeed(): Promise<string> {
} }
async function loginSeed(seed: string): Promise<string> { async function loginSeed(seed: string): Promise<string> {
const res = await fetch(`${API_V1}/auth/seed/login`, { const res = await fetchWithRetry(`${API_V1}/auth/seed/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed }), body: JSON.stringify({ seed }),
@@ -133,7 +179,7 @@ async function ensureToken(): Promise<string> {
export async function fetchNodes(): Promise<ServerNode[]> { export async function fetchNodes(): Promise<ServerNode[]> {
const token = await ensureToken(); const token = await ensureToken();
const res = await fetch(`${API_V1}/nodes`, { const res = await fetchWithRetry(`${API_V1}/nodes`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (!res.ok) throw new Error(`GET /nodes failed: ${res.status}`); if (!res.ok) throw new Error(`GET /nodes failed: ${res.status}`);
@@ -172,7 +218,7 @@ export async function loginWithSeed(seed: string): Promise<void> {
* поэтому это одноразовый обмен, а не поллинг: `true` при первом же успешном вызове. * поэтому это одноразовый обмен, а не поллинг: `true` при первом же успешном вызове.
*/ */
export async function pollTelegramLogin(authCode: string): Promise<boolean> { export async function pollTelegramLogin(authCode: string): Promise<boolean> {
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) { if (res.status === 401 || res.status === 410) {
throw new Error("TELEGRAM_LOGIN_EXPIRED"); throw new Error("TELEGRAM_LOGIN_EXPIRED");
} }
@@ -194,7 +240,7 @@ export interface UsageInfo {
/** Текущий расход/лимит трафика юзера — для индикатора на `VpnStats`. */ /** Текущий расход/лимит трафика юзера — для индикатора на `VpnStats`. */
export async function fetchUsage(): Promise<UsageInfo> { export async function fetchUsage(): Promise<UsageInfo> {
const token = await ensureToken(); const token = await ensureToken();
const res = await fetch(`${API_V1}/users/me`, { const res = await fetchWithRetry(`${API_V1}/users/me`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (!res.ok) throw new Error(`GET /users/me failed: ${res.status}`); 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<string | null> { export async function getBotUsername(): Promise<string | null> {
if (cachedBotUsername) return cachedBotUsername; if (cachedBotUsername) return cachedBotUsername;
try { try {
const res = await fetch(`${API_V1}/config`); const res = await fetchWithRetry(`${API_V1}/config`);
if (!res.ok) return null; if (!res.ok) return null;
const data = (await res.json()) as { bot_username: string }; const data = (await res.json()) as { bot_username: string };
cachedBotUsername = data.bot_username; cachedBotUsername = data.bot_username;