From adecf2548666193639631a0ef03e4d1ec78b1c14 Mon Sep 17 00:00:00 2001 From: AliReza Date: Sat, 3 Oct 2026 16:05:59 +0330 Subject: [PATCH] http: only a 401 from /auth/refresh signs the user out A 429, 5xx or network failure during token refresh used to sign the user out. Behind one school Wi-Fi or mobile CGNAT the backend's per-IP refresh limit was hit, and phone users were sent back to SMS login. Now those cases keep the session and surface as a retryable error. Co-Authored-By: Claude Opus 5.5 --- src/api/http.ts | 45 +++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/api/http.ts b/src/api/http.ts index c2be150..2c4212a 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -163,7 +163,7 @@ export function createHttpClient(options: HttpClientOptions) { const inFlight = new Map>(); const signedOutListeners = new Set<() => void>(); - let refreshing: Promise | null = null; + let refreshing: Promise | null = null; function authHeaders(): Record { const token = storage.get()?.token; @@ -175,27 +175,39 @@ export function createHttpClient(options: HttpClientOptions) { signedOutListeners.forEach((listener) => listener()); } - async function refreshTokens(): Promise { + /** + * نتیجه‌ی تمدید سه حالت دارد و فقط یکی‌اش یعنی خروج: + * • Tokens — تمدید شد. + * • 'invalid' — سرور گفت refresh token معتبر نیست (۴۰۱)؛ نشست واقعاً تمام است. + * • ApiError — سرور در دسترس نبود یا سقف را زد (۴۲۹، ۵xx، قطعیِ شبکه)؛ نشست سالم است. + * تا ۲۰۲۶-۱۰-۰۳ هر شکستی خروج حساب می‌شد: پشتِ وای‌فایِ مدرسه که سقفِ تمدیدِ IP پر می‌شد، + * دانش‌آموزِ شماره‌دار به صفحه‌ی ورود پرت می‌شد و باید دوباره پیامک می‌گرفت. + */ + async function refreshTokens(): Promise { const current = storage.get(); - if (!current?.refreshToken) return null; + if (!current?.refreshToken) return 'invalid'; if (refreshing) return refreshing; - refreshing = (async () => { + refreshing = (async (): Promise => { try { - const response = await fetch(`${baseUrl}/auth/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refreshToken: current.refreshToken }), - signal: timeoutSignal(defaultTimeout), - credentials: 'omit', - }); - if (!response.ok) return null; + let response: Response; + try { + response = await fetch(`${baseUrl}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: current.refreshToken }), + signal: timeoutSignal(defaultTimeout), + credentials: 'omit', + }); + } catch { + return new ApiError(0, 'ارتباط با سرور برقرار نشد'); + } + if (response.status === 401) return 'invalid'; + if (!response.ok) return errorFrom(response, await parseBody(response)); const data = (await response.json()) as Tokens; const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken }; storage.set(next); return next; - } catch { - return null; } finally { queueMicrotask(() => { refreshing = null; @@ -243,8 +255,9 @@ export function createHttpClient(options: HttpClientOptions) { let response = await send(method, path, body, opts); if (response.status === 401 && hadToken) { const refreshed = await refreshTokens(); - if (refreshed) response = await send(method, path, body, opts); - else signOut(); + if (refreshed === 'invalid') signOut(); + else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه + else response = await send(method, path, body, opts); } return response; }