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 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user