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 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:05:59 +03:30
parent 33f5f8d009
commit adecf25486

View File

@@ -163,7 +163,7 @@ export function createHttpClient(options: HttpClientOptions) {
const inFlight = new Map<string, Promise<unknown>>(); const inFlight = new Map<string, Promise<unknown>>();
const signedOutListeners = new Set<() => void>(); const signedOutListeners = new Set<() => void>();
let refreshing: Promise<Tokens | null> | null = null; let refreshing: Promise<Tokens | 'invalid' | ApiError> | null = null;
function authHeaders(): Record<string, string> { function authHeaders(): Record<string, string> {
const token = storage.get()?.token; const token = storage.get()?.token;
@@ -175,27 +175,39 @@ export function createHttpClient(options: HttpClientOptions) {
signedOutListeners.forEach((listener) => listener()); signedOutListeners.forEach((listener) => listener());
} }
async function refreshTokens(): Promise<Tokens | null> { /**
* نتیجه‌ی تمدید سه حالت دارد و فقط یکی‌اش یعنی خروج:
* • Tokens — تمدید شد.
* • 'invalid' — سرور گفت refresh token معتبر نیست (۴۰۱)؛ نشست واقعاً تمام است.
* • ApiError — سرور در دسترس نبود یا سقف را زد (۴۲۹، ۵xx، قطعیِ شبکه)؛ نشست سالم است.
* تا ۲۰۲۶-۱۰-۰۳ هر شکستی خروج حساب می‌شد: پشتِ وای‌فایِ مدرسه که سقفِ تمدیدِ IP پر می‌شد،
* دانش‌آموزِ شماره‌دار به صفحه‌ی ورود پرت می‌شد و باید دوباره پیامک می‌گرفت.
*/
async function refreshTokens(): Promise<Tokens | 'invalid' | ApiError> {
const current = storage.get(); const current = storage.get();
if (!current?.refreshToken) return null; if (!current?.refreshToken) return 'invalid';
if (refreshing) return refreshing; if (refreshing) return refreshing;
refreshing = (async () => { refreshing = (async (): Promise<Tokens | 'invalid' | ApiError> => {
try { try {
const response = await fetch(`${baseUrl}/auth/refresh`, { let response: Response;
try {
response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: current.refreshToken }), body: JSON.stringify({ refreshToken: current.refreshToken }),
signal: timeoutSignal(defaultTimeout), signal: timeoutSignal(defaultTimeout),
credentials: 'omit', credentials: 'omit',
}); });
if (!response.ok) return null; } 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 data = (await response.json()) as Tokens;
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken }; const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
storage.set(next); storage.set(next);
return next; return next;
} catch {
return null;
} finally { } finally {
queueMicrotask(() => { queueMicrotask(() => {
refreshing = null; refreshing = null;
@@ -243,8 +255,9 @@ export function createHttpClient(options: HttpClientOptions) {
let response = await send(method, path, body, opts); let response = await send(method, path, body, opts);
if (response.status === 401 && hadToken) { if (response.status === 401 && hadToken) {
const refreshed = await refreshTokens(); const refreshed = await refreshTokens();
if (refreshed) response = await send(method, path, body, opts); if (refreshed === 'invalid') signOut();
else signOut(); else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
else response = await send(method, path, body, opts);
} }
return response; return response;
} }