fix(frontend): keep workspace interactive when SSR auth probe cannot reach gateway (#3493) (#3495)

* fix(frontend): keep workspace interactive when SSR auth probe cannot reach gateway (#3493)

When the SSR auth probe at /api/v1/auth/me times out or fails, the
workspace layout used to render a static fallback page without
AuthProvider or QueryClientProvider, making logout and every other
interaction non-functional until the gateway recovered.

Render the normal WorkspaceContent in 'gateway_unavailable' mode
instead, surfacing a polite offline banner that re-probes the gateway
in the background and hides itself the moment refreshUser() returns
an authenticated user. The probe is reentrancy-guarded so a slow
gateway cannot pile up parallel /auth/me requests.

Closes #3493

* fix(workspace): silent probe in offline banner to avoid /login redirect during gateway recovery (#3493)

The banner previously delegated retry probes to AuthProvider.refreshUser(),
which treats any 401 from /api/v1/auth/me as 'session expired' and
force-redirects to /login. During gateway recovery, the first few requests
may transiently return 401 before the gateway is fully ready, which would
incorrectly kick the user out — defeating the purpose of the offline banner.

Now the banner silently fetches /api/v1/auth/me itself and only delegates
to refreshUser() on 200 OK. Non-200 responses (401 / 5xx / network) are
swallowed and retried on the next interval tick, ensuring the user stays
logged in across short gateway outages.

Verified in Docker:
- docker pause deer-flow-gateway → banner appears, page interactive
- docker unpause deer-flow-gateway → banner auto-disappears within 10s,
  user remains on /workspace/chats/new with full session restored
- All 117 unit tests pass

* fix(workspace): fix banner polling leak and persistent 401 handling (#3493)
- Stop polling immediately after user recovery: add user to effect dependencies, cleanup interval when user !== null
- Handle persistent 401: trigger login redirect after 3 consecutive unauthorized responses
- Extract decision logic to pure helper, add 8 unit tests covering all critical paths

* fix(workspace): address CR feedback on gateway offline recovery (#3493)

- gateway-offline-banner-helpers: decrement (not reset) auth-failure
  streak on transient outcomes so a flapping gateway (401 alternating
  with 5xx) still converges on session-expired
- gateway-offline-banner: reuse probe response body to apply user
  directly via new AuthProvider.applyUser, halving the recovery burst
  against an already-struggling gateway
- gateway-offline-banner: extract classifyProbe into helpers for unit
  testability; log probe failures via console.warn instead of swallowing
- gateway-offline-fallback: new shared component used by both workspace
  and (auth) layouts so auth pages recover the same way the workspace
  does, fixing the lockup where bare static HTML had no AuthProvider
- AuthProvider.logout: fall back to hard navigation when the gateway
  logout fetch fails, matching legacy form-POST behaviour and avoiding
  stale client state during outage
- tests: extend gateway-offline-banner-helpers.test with flapping
  convergence and classifyProbe branch coverage (19 cases total)
This commit is contained in:
Huixin615
2026-06-11 21:14:49 +08:00
committed by GitHub
parent f401e7baa6
commit b6fbf0d105
12 changed files with 563 additions and 41 deletions
+29 -2
View File
@@ -26,6 +26,7 @@ interface AuthContextType {
isLoading: boolean;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
applyUser: (user: User | null) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -52,6 +53,15 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
const isAuthenticated = user !== null;
/**
* Apply a user value supplied by a caller (e.g. banner probe) that has
* already fetched it. Equivalent to setUser, exposed with a stable name
* so consumers don't reach into React internals.
*/
const applyUser = useCallback((next: User | null) => {
setUser(next);
}, []);
/**
* Fetch current user from FastAPI
* Used when initialUser might be stale (e.g., after tab was inactive)
@@ -87,6 +97,13 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
/**
* Logout - call FastAPI logout endpoint and clear local state
* Per RFC-001: Immediately clear local state, don't wait for server confirmation
*
* When the gateway is unreachable the fetch silently fails — the SPA
* router.push("/") would leave the user on "/" still holding stale
* React state and any in-flight SSE / fetch / query subscriptions.
* We therefore fall back to a hard navigation (window.location.href),
* which discards all client state the same way the legacy form-POST
* logout used to.
*/
const logout = useCallback(async () => {
// Immediately clear local state to prevent UI flicker
@@ -97,14 +114,23 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
return;
}
let logoutFailed = false;
try {
await fetch("/api/v1/auth/logout", {
const res = await fetch("/api/v1/auth/logout", {
method: "POST",
credentials: "include",
});
if (!res.ok) logoutFailed = true;
} catch (err) {
console.error("Logout request failed:", err);
// Still redirect even if logout request fails
logoutFailed = true;
}
if (logoutFailed && typeof window !== "undefined") {
// Hard navigation ensures every in-flight subscription is torn down,
// matching the legacy form-POST logout behaviour during a gateway outage.
window.location.href = "/";
return;
}
// Redirect to home page
@@ -140,6 +166,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
isLoading,
logout,
refreshUser,
applyUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;