From 296ce60c78627739a9908832d262d2516260cd4c Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Sun, 9 Aug 2026 01:06:15 +0300 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D0=BD=D0=B5=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F,=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D1=83=20=D0=BF=D0=BB=D0=BE=D1=85=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silent-refresh считал неудачей любой не-2xx ответ и на каждую такую неудачу сбрасывал access-токен с редиректом на /login. Ответ 500 — это «серверу плохо», а не «вы не авторизованы»: 07.08.2026 refresh отвечал 500 из-за исчерпанного пула БД, и фронтенд разлогинивал людей посреди работы, а повторный вход падал тем же 500. `refreshAccessToken` теперь различает причины: `invalid` (backend отверг сессию — 4xx, единственный случай для разлогина), `unavailable` (5xx, таймаут, обрыв сети — сессия цела, токен сохраняется, пользователь получает обычную ошибку запроса) и `ok`. Восстановление сессии при старте приложения на `unavailable` повторяет попытку трижды с задержками 1/2/4 с, вместо того чтобы сразу объявить пользователя неавторизованным. --- frontend/src/api/client.ts | 63 ++++++++++++++++++++++++------ frontend/src/auth/AuthProvider.tsx | 24 +++++++++++- 2 files changed, 72 insertions(+), 15 deletions(-) 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 }