From d4d5edececa68cd359a142f53781047ae2212dc2 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 23 Oct 2026 08:16:09 +0300 Subject: [PATCH] fix: keep session when refresh fails on network error or 5xx (WealthLedger/frontend#97) A backend outage during deploy cleared the session and sent the user to login although the refresh cookie was still valid. Only a definitive 401 ends the session now; network errors and 5xx keep it and show a retry screen. --- .ai/conventions/data.md | 5 ++ src/app/routes/AppRoutes/AppRoutes.test.tsx | 24 +++++++++ src/app/routes/RouteGuards.tsx | 19 ++++++- src/features/auth/model/auth-context.ts | 5 ++ src/features/auth/model/auth.test.tsx | 59 ++++++++++++++++++--- src/features/auth/model/auth.vm.ts | 28 +++++++--- src/shared/api/client.test.ts | 19 +++++++ src/shared/api/client.ts | 7 ++- src/shared/i18n/locales/ru.ts | 3 ++ 9 files changed, 154 insertions(+), 15 deletions(-) diff --git a/.ai/conventions/data.md b/.ai/conventions/data.md index 34b6e01..7283c1f 100644 --- a/.ai/conventions/data.md +++ b/.ai/conventions/data.md @@ -40,6 +40,11 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry. Не добавляй собственный refresh/retry loop в caller. +Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx` +(например, деплой backend) не доказывают конец session: она сохраняется, +`unavailable` показывает retry-экран вместо login, а следующий `401` снова +запускает refresh. Retry/backoff внутри refresh не добавляется. + Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth synchronization не требуется; допустим reload/re-login. Web Locks и отдельные token stores не добавляются. diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx index 40990f6..ca97244 100644 --- a/src/app/routes/AppRoutes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx @@ -190,6 +190,30 @@ describe("AppRoutes", () => { expect(document.title).toBe("Карты — WealthLedger"); }); + it("offers a retry instead of the login form while the server is unreachable, then restores the route", async () => { + let isOutage = true; + server.use( + http.post("*/api/v1/auth/refresh", () => + isOutage + ? new HttpResponse(null, { status: 502 }) + : HttpResponse.json({ + accessToken: "test-token", + user: { email: "user@example.com", id: "user-1" }, + }), + ), + ); + renderRoutes("/accounts"); + + expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось подключиться"); + expect(screen.queryByRole("heading", { name: "Вход" })).not.toBeInTheDocument(); + isOutage = false; + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + + expect( + await screen.findByRole("heading", { level: 2, name: "Нет инвестиционных счетов" }), + ).toBeVisible(); + }); + it("lazy-loads the protected shell for an authenticated accounts route", async () => { server.use( http.post("*/api/v1/auth/refresh", () => diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx index 5bdff06..f60a634 100644 --- a/src/app/routes/RouteGuards.tsx +++ b/src/app/routes/RouteGuards.tsx @@ -1,4 +1,7 @@ +import { Button, Container } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import { type FC, type PropsWithChildren, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { useAuth } from "@/features/auth"; @@ -7,12 +10,26 @@ import { RouteSkeleton } from "./RouteFallback"; const Protected: FC = (props) => { const { children } = props; - const { ready, signedOut, user } = useAuth(); + const { ready, retry, signedOut, unavailable, user } = useAuth(); + const { t } = useTranslation(); const location = useLocation(); const [wasAuthenticated, setWasAuthenticated] = useState(false); if (user && !wasAuthenticated) setWasAuthenticated(true); if (!ready) return ; + // The server was unreachable (e.g. mid-deploy), so the session is unproven, not gone. + if (!user && unavailable) + return ( + + {t("auth.retry")}} + error + text={t("auth.unavailableText")} + title={t("auth.unavailableTitle")} + /> + + ); + const loginState = signedOut && wasAuthenticated ? undefined : { returnTo: `${location.pathname}${location.search}` }; diff --git a/src/features/auth/model/auth-context.ts b/src/features/auth/model/auth-context.ts index 1a0ed08..008a81f 100644 --- a/src/features/auth/model/auth-context.ts +++ b/src/features/auth/model/auth-context.ts @@ -7,7 +7,12 @@ type AuthContextValue = { logout: () => Promise; ready: boolean; register: (email: string, password: string) => Promise; + retry: () => void; signedOut: boolean; + /** + Refresh failed with a network error or 5xx; the refresh cookie may still be valid. + */ + unavailable: boolean; user: undefined | User; }; diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index ce7bb17..e2dc24d 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -92,8 +92,10 @@ describe("AuthProvider", () => { ); }); - it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => { - server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error())); + it("settles unauthenticated and clears cached data when cold-start refresh is rejected with 401", async () => { + server.use(http.post("*/api/v1/auth/refresh", () => + HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), + )); const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); @@ -103,12 +105,54 @@ describe("AuthProvider", () => { await waitFor(() => expect(result.current.ready).toBe(true)); expect(result.current.user).toBeUndefined(); + expect(result.current.unavailable).toBe(false); expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); - it("keeps the original protected 401 when serialized refresh has a transport error", async () => { + it.each([ + ["a transport error", () => HttpResponse.error()], + ["a 502 from the proxy", () => new HttpResponse(null, { status: 502 })], + ["a 503 problem", () => HttpResponse.json({ code: "INTERNAL_SERVER_ERROR" }, { status: 503 })], + ])("settles as unavailable and keeps cached data when cold-start refresh gets %s", async (_name, failure) => { + server.use(http.post("*/api/v1/auth/refresh", failure)); + const queryClient = createTestQueryClient(); + queryClient.setQueryData(["cached-query"], { value: "kept" }); + const wrapper: FC = createAuthWrapper(queryClient); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => expect(result.current.ready).toBe(true)); + + expect(result.current.user).toBeUndefined(); + expect(result.current.unavailable).toBe(true); + expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "kept" }); + }); + + it("restores the session when retry succeeds after an outage", async () => { + let isOutage = true; + server.use(http.post("*/api/v1/auth/refresh", () => + isOutage + ? new HttpResponse(null, { status: 502 }) + : HttpResponse.json({ + accessToken: "restored-token", + user: { email: "user@example.com", id: "user-1" }, + }), + )); + const queryClient = createTestQueryClient(); + const wrapper: FC = createAuthWrapper(queryClient); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => expect(result.current.unavailable).toBe(true)); + isOutage = false; + act(() => result.current.retry()); + + await waitFor(() => expect(result.current.user?.id).toBe("user-1")); + expect(result.current.unavailable).toBe(false); + expect(await requestAuthorization()).toBe("Bearer restored-token"); + }); + + it("keeps the original protected 401 and the session when serialized refresh has a transport error", async () => { let refreshCalls = 0; server.use( http.post("*/api/v1/auth/refresh", () => { @@ -137,10 +181,11 @@ describe("AuthProvider", () => { const response = await apiClient.GET("/api/v1/brokerage-accounts"); expect(response.response.status).toBe(401); - await waitFor(() => expect(result.current.user).toBeUndefined()); - expect(await requestAuthorization()).toBeNull(); - expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); - expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); + // An unreachable server does not prove the session ended; the next 401 refreshes again. + await waitFor(() => expect(result.current.unavailable).toBe(true)); + expect(result.current.user?.id).toBe("user-1"); + expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "stale" }); + expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "bank-card-1" }]); }); it("clears connection status and financial cache independently on logout", async () => { diff --git a/src/features/auth/model/auth.vm.ts b/src/features/auth/model/auth.vm.ts index 4079923..5cfa2a8 100644 --- a/src/features/auth/model/auth.vm.ts +++ b/src/features/auth/model/auth.vm.ts @@ -5,6 +5,9 @@ import { apiClient, configureAuthClient, type paths } from "@/shared/api"; import { type User } from "./auth-context"; +// A 5xx or network error means the server is unreachable, not that the session ended. +const HTTP_SERVER_ERROR = 500; + type Authentication = paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"]; @@ -32,6 +35,8 @@ function useAuthProviderVm() { const accessTokenRef = useRef(undefined); const [ready, setReady] = useState(false); const [signedOut, setSignedOut] = useState(false); + const [unavailable, setUnavailable] = useState(false); + const unavailableRef = useRef(false); const generationRef = useRef(0); const userRef = useRef(undefined); @@ -49,12 +54,18 @@ function useAuthProviderVm() { return next; }, []); + const markUnavailable = useCallback((isUnavailable: boolean) => { + unavailableRef.current = isUnavailable; + setUnavailable(isUnavailable); + }, []); + const applyAuthentication = useCallback( (authentication: Authentication) => { const changedIdentity = userRef.current && userRef.current.id !== authentication.user.id; setSignedOut(false); + markUnavailable(false); accessTokenRef.current = authentication.accessToken; if (changedIdentity) { @@ -67,16 +78,17 @@ function useAuthProviderVm() { userRef.current = authentication.user; setUser(authentication.user); }, - [queryClient], + [markUnavailable, queryClient], ); const clearSession = useCallback(() => { + markUnavailable(false); generationRef.current += 1; userRef.current = undefined; setUser(undefined); accessTokenRef.current = undefined; queryClient.clear(); - }, [queryClient]); + }, [markUnavailable, queryClient]); const refresh = useCallback((): Promise => { if (refreshPromiseRef.current) return refreshPromiseRef.current; @@ -86,11 +98,12 @@ function useAuthProviderVm() { if (started !== generationRef.current) return false; try { - const { data } = await apiClient.POST("/api/v1/auth/refresh"); + const { data, response } = await apiClient.POST("/api/v1/auth/refresh"); if (started !== generationRef.current) return false; if (!data) { - clearSession(); + if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true); + else clearSession(); return false; } @@ -100,7 +113,7 @@ function useAuthProviderVm() { return true; } catch { - if (started === generationRef.current) clearSession(); + if (started === generationRef.current) markUnavailable(true); return false; } @@ -112,7 +125,7 @@ function useAuthProviderVm() { refreshPromiseRef.current = pending; return pending; - }, [applyAuthentication, clearSession, serialize]); + }, [applyAuthentication, clearSession, markUnavailable, serialize]); useEffect(() => { void refresh().finally(() => setReady(true)); @@ -146,6 +159,7 @@ function useAuthProviderVm() { clear: clearSession, generation: () => generationRef.current, refresh, + unavailable: () => unavailableRef.current, }); }); useEffect(() => () => configureAuthClient(), []); @@ -157,7 +171,9 @@ function useAuthProviderVm() { ready, register: (email: string, password: string) => authenticate("/api/v1/auth/register", email, password), + retry: () => void refresh(), signedOut, + unavailable, user, }; diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts index 476cb17..d684c0d 100644 --- a/src/shared/api/client.test.ts +++ b/src/shared/api/client.test.ts @@ -67,6 +67,25 @@ describe("apiClient authentication", () => { expect(clear).toHaveBeenCalledOnce(); }); + it("keeps the session when refresh failed because the server was unreachable", async () => { + const clear = vi.fn(); + server.use( + http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), + ), + ); + configureAuthClient({ + accessToken: () => "expired", + clear, + refresh: () => Promise.resolve(false), + unavailable: () => true, + }); + + await apiClient.GET("/api/v1/brokerage-accounts"); + + expect(clear).not.toHaveBeenCalled(); + }); + it("does not attach Bearer auth to authentication endpoints", async () => { let authorization: null | string diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index d9dbe75..8ac77e2 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -32,6 +32,10 @@ type AuthHandlers = { clear: () => void; generation?: () => number; refresh: () => Promise; + /** + True after a refresh failed transiently (network/5xx): the session is not proven gone. + */ + unavailable?: () => boolean; }; type ReadAttempt = { @@ -100,7 +104,8 @@ async function authenticatedFetch(request: Request): Promise { if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); if (!(await sessionState.handlers.refresh())) { - if (isCurrent()) sessionState.handlers.clear(); + if (isCurrent() && !sessionState.handlers.unavailable?.()) + sessionState.handlers.clear(); return response; } diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 2f7d165..7bc3abb 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -53,6 +53,9 @@ const ru = { passwordConfirmation: "Подтвердите пароль", register: "Регистрация", requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.", + retry: "Повторить", + unavailableText: "Сервер временно недоступен. Ваш вход сохранён, попробуйте через минуту.", + unavailableTitle: "Не удалось подключиться", validation: { invalidEmail: "Некорректный email", invalidPasswordLength: "Пароль: от 8 до 72 символов, только латинские буквы, цифры, спецсимволы и пробел",