diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ccb3e2e..63ecfd1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,8 +4,15 @@ * - Access-токен подставляется из authStore (память, не localStorage). * - На 401 выполняется один silent-refresh (POST /auth/refresh, * credentials: 'include' — сессия читается из httpOnly-cookie) и повтор - * исходного запроса. Если refresh не удался — access-токен сбрасывается и - * выполняется редирект на /login. + * исходного запроса. + * - ⚠️ Причина неудачи refresh различается (`RefreshOutcome`). Сессия + * сбрасывается ТОЛЬКО когда backend сказал, что она недействительна + * (`invalid`). Ответ 5xx или обрыв сети — это «серверу плохо», а не «вы не + * авторизованы»: токен сохраняется, пользователь остаётся в системе и + * получает обычную ошибку запроса. Раньше различия не было, и на + * нагрузочном тесте 07.08.2026 (когда refresh отвечал 500 из-за + * исчерпанного пула БД) фронтенд разлогинивал людей посреди работы, а + * повторный вход падал тем же 500. * - Параллельные 401 схлопываются в один refresh-запрос (refreshPromise). */ import { authStore } from '@/auth/authStore' @@ -44,26 +51,48 @@ interface RequestOptions extends Omit { skipAuthRefresh?: boolean } -let refreshPromise: Promise | null = null +/** + * Итог silent-refresh. + * + * - `ok` — выдан новый access-токен; + * - `invalid` — backend отверг refresh-сессию (просрочена, отозвана, reuse): + * единственный случай, когда пользователя правда надо разлогинить; + * - `unavailable` — до ответа «сессия недействительна» дело не дошло: 5xx, + * таймаут или обрыв сети. Сессия при этом цела, `status` — HTTP-код + * ответа или `null`, если запрос не доехал вовсе. + */ +export type RefreshOutcome = + | { result: 'ok' } + | { result: 'invalid' } + | { result: 'unavailable'; status: number | null } + +let refreshPromise: Promise | null = null /** * Выполняет silent-refresh access-токена через httpOnly refresh-cookie. - * Возвращает true при успехе. Параллельные вызовы переиспользуют один запрос. + * Параллельные вызовы переиспользуют один запрос. */ -export async function refreshAccessToken(): Promise { +export async function refreshAccessToken(): Promise { if (!refreshPromise) { - refreshPromise = (async () => { + refreshPromise = (async (): Promise => { try { const response = await fetch(`${API_BASE}/auth/refresh`, { method: 'POST', credentials: 'include', }) - if (!response.ok) return false - const data = (await response.json()) as { access_token: string } - authStore.setAccessToken(data.access_token) - return true + if (response.ok) { + const data = (await response.json()) as { access_token: string } + authStore.setAccessToken(data.access_token) + return { result: 'ok' } + } + // Про недействительность сессии backend говорит только кодом 4xx. + // Всё остальное (500/502/503/504) — состояние сервера, а не сессии. + return response.status >= 500 + ? { result: 'unavailable', status: response.status } + : { result: 'invalid' } } catch { - return false + // Сеть не доехала — про сессию мы так ничего и не узнали. + return { result: 'unavailable', status: null } } finally { refreshPromise = null } @@ -124,9 +153,17 @@ export async function apiRequest(path: string, options: RequestOpti let response = await doFetch() if (response.status === 401 && !skipAuthRefresh) { - const refreshed = await refreshAccessToken() - if (refreshed) { + const outcome = await refreshAccessToken() + if (outcome.result === 'ok') { response = await doFetch() + } else if (outcome.result === 'unavailable') { + // Серверу плохо — сессию не трогаем и на /login не выкидываем: + // как только backend оживёт, следующий запрос обновит токен сам. + throw new ApiError( + outcome.status ?? 0, + null, + 'Сервер временно недоступен. Попробуйте ещё раз через минуту.', + ) } else { redirectToLogin() throw new ApiError(401, null, 'Сессия истекла') diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index 1b5cc7c..5961784 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -4,6 +4,20 @@ import { authStore } from '@/auth/authStore' import { refreshAccessToken } from '@/api/client' import { AuthContext, type AuthContextValue, type AuthStatus } from '@/auth/authContext' +/** + * Задержки повторов восстановления сессии, если backend отвечает 5xx. + * + * Недоступность сервера — не повод объявлять пользователя неавторизованным: + * refresh-cookie цела, и через несколько секунд сессия обычно поднимается + * сама. Повторов ровно три (суммарно ~7 с) — дальше показываем страницу + * входа, потому что бесконечный спиннер хуже честного «войдите заново»: + * cookie при этом не стирается, и повторная попытка входа сработает, как + * только backend оживёт. + */ +const BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000] + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + /** * Провайдер сессии пользователя. * При монтировании приложения пытается восстановить сессию через @@ -18,9 +32,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { let cancelled = false async function bootstrap() { - const restored = await refreshAccessToken() + let outcome = await refreshAccessToken() + for (const delay of BOOTSTRAP_RETRY_DELAYS_MS) { + if (cancelled || outcome.result !== 'unavailable') break + await sleep(delay) + if (cancelled) return + outcome = await refreshAccessToken() + } if (cancelled) return - if (!restored) { + if (outcome.result !== 'ok') { setStatus('unauthenticated') return }