api.ts: retry с экспоненциальным backoff для сетевых сбоев
Различает настоящие сетевые сбои/5xx (ретраить) от осмысленных 4xx-ответов приложения (не ретраить) — раньше единичный сетевой глитч ронял весь флоу без единой попытки повтора. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+52
-6
@@ -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<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 из бэкенда) ---
|
||||
export interface ServerNode {
|
||||
id: string;
|
||||
@@ -89,7 +135,7 @@ function isTokenValid(token: string | null): token is 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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -101,7 +147,7 @@ async function generateSeed(): 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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ seed }),
|
||||
@@ -133,7 +179,7 @@ async function ensureToken(): Promise<string> {
|
||||
|
||||
export async function fetchNodes(): Promise<ServerNode[]> {
|
||||
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<void> {
|
||||
* поэтому это одноразовый обмен, а не поллинг: `true` при первом же успешном вызове.
|
||||
*/
|
||||
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) {
|
||||
throw new Error("TELEGRAM_LOGIN_EXPIRED");
|
||||
}
|
||||
@@ -194,7 +240,7 @@ export interface UsageInfo {
|
||||
/** Текущий расход/лимит трафика юзера — для индикатора на `VpnStats`. */
|
||||
export async function fetchUsage(): Promise<UsageInfo> {
|
||||
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<string | null> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user