mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 18:05:58 +00:00
b6fbf0d105
* 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)
186 lines
6.0 KiB
TypeScript
186 lines
6.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD,
|
|
OFFLINE_BANNER_RETRY_INTERVAL_MS,
|
|
classifyProbe,
|
|
decideProbeAction,
|
|
shouldShowOfflineBanner,
|
|
} from "@/components/workspace/gateway-offline-banner-helpers";
|
|
import type { User } from "@/core/auth/types";
|
|
|
|
const fakeUser: User = {
|
|
id: "u1",
|
|
email: "user@example.com",
|
|
system_role: "user",
|
|
needs_setup: false,
|
|
};
|
|
|
|
function makeResponse(status: number, ok = status >= 200 && status < 300) {
|
|
return { status, ok } as Response;
|
|
}
|
|
|
|
describe("shouldShowOfflineBanner", () => {
|
|
it("hides when the gateway is reachable", () => {
|
|
expect(shouldShowOfflineBanner(null, false)).toBe(false);
|
|
expect(shouldShowOfflineBanner(fakeUser, false)).toBe(false);
|
|
});
|
|
|
|
it("shows when the gateway is unavailable and the client has no user yet", () => {
|
|
expect(shouldShowOfflineBanner(null, true)).toBe(true);
|
|
});
|
|
|
|
it("hides as soon as the client recovers an authenticated user", () => {
|
|
expect(shouldShowOfflineBanner(fakeUser, true)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("OFFLINE_BANNER_RETRY_INTERVAL_MS", () => {
|
|
it("is a positive finite number", () => {
|
|
expect(OFFLINE_BANNER_RETRY_INTERVAL_MS).toBeGreaterThan(0);
|
|
expect(Number.isFinite(OFFLINE_BANNER_RETRY_INTERVAL_MS)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD", () => {
|
|
it("is an integer greater than 1 so a single transient 401 cannot expire the session", () => {
|
|
expect(Number.isInteger(OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD)).toBe(true);
|
|
expect(OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
describe("classifyProbe", () => {
|
|
it("returns transient when fetch errored", () => {
|
|
expect(classifyProbe(null, true)).toEqual({ kind: "transient" });
|
|
});
|
|
|
|
it("returns transient when response is null with no error flag", () => {
|
|
expect(classifyProbe(null, false)).toEqual({ kind: "transient" });
|
|
});
|
|
|
|
it("returns ok with parsed user for a 2xx response with body", () => {
|
|
expect(classifyProbe(makeResponse(200), false, fakeUser)).toEqual({
|
|
kind: "ok",
|
|
user: fakeUser,
|
|
});
|
|
});
|
|
|
|
it("returns transient for a 2xx response whose body failed to parse", () => {
|
|
// Defensive: a 200 with malformed JSON / schema mismatch should not be
|
|
// treated as 'ok' because the caller has no user to apply.
|
|
expect(classifyProbe(makeResponse(200), false, null)).toEqual({
|
|
kind: "transient",
|
|
});
|
|
});
|
|
|
|
it("returns unauthorized for a 401 response", () => {
|
|
expect(classifyProbe(makeResponse(401), false)).toEqual({
|
|
kind: "unauthorized",
|
|
});
|
|
});
|
|
|
|
it("returns transient for 5xx responses", () => {
|
|
expect(classifyProbe(makeResponse(503), false)).toEqual({
|
|
kind: "transient",
|
|
});
|
|
expect(classifyProbe(makeResponse(500), false)).toEqual({
|
|
kind: "transient",
|
|
});
|
|
});
|
|
|
|
it("returns transient for unexpected non-401 4xx responses", () => {
|
|
expect(classifyProbe(makeResponse(429), false)).toEqual({
|
|
kind: "transient",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("decideProbeAction", () => {
|
|
it("returns apply-user with the body on a 2xx response", () => {
|
|
expect(decideProbeAction(0, { kind: "ok", user: fakeUser })).toEqual({
|
|
type: "apply-user",
|
|
user: fakeUser,
|
|
});
|
|
// Even if we'd accumulated some 401s, a 200 wins immediately.
|
|
expect(decideProbeAction(2, { kind: "ok", user: fakeUser })).toEqual({
|
|
type: "apply-user",
|
|
user: fakeUser,
|
|
});
|
|
});
|
|
|
|
it("treats a single 401 as transient noise and only bumps the counter", () => {
|
|
expect(decideProbeAction(0, { kind: "unauthorized" })).toEqual({
|
|
type: "noop",
|
|
nextFailureCount: 1,
|
|
});
|
|
});
|
|
|
|
it("treats consecutive 401s below the threshold as still transient", () => {
|
|
expect(decideProbeAction(1, { kind: "unauthorized" })).toEqual({
|
|
type: "noop",
|
|
nextFailureCount: 2,
|
|
});
|
|
});
|
|
|
|
it("delegates to refreshUser as 'session-expired' once 401s reach the threshold", () => {
|
|
expect(decideProbeAction(2, { kind: "unauthorized" })).toEqual({
|
|
type: "delegate-refresh",
|
|
reason: "session-expired",
|
|
});
|
|
});
|
|
|
|
it("honours a custom threshold (parameterised for safer tests)", () => {
|
|
expect(decideProbeAction(0, { kind: "unauthorized" }, 2)).toEqual({
|
|
type: "noop",
|
|
nextFailureCount: 1,
|
|
});
|
|
expect(decideProbeAction(1, { kind: "unauthorized" }, 2)).toEqual({
|
|
type: "delegate-refresh",
|
|
reason: "session-expired",
|
|
});
|
|
});
|
|
|
|
it("decrements (not resets) the auth-failure streak on a transient outcome", () => {
|
|
// Was 2 → 1, so a flapping gateway (401↔5xx) still converges on the
|
|
// threshold instead of indefinitely masking session expiry.
|
|
expect(decideProbeAction(2, { kind: "transient" })).toEqual({
|
|
type: "noop",
|
|
nextFailureCount: 1,
|
|
});
|
|
// Floored at 0; never goes negative.
|
|
expect(decideProbeAction(0, { kind: "transient" })).toEqual({
|
|
type: "noop",
|
|
nextFailureCount: 0,
|
|
});
|
|
expect(decideProbeAction(1, { kind: "transient" })).toEqual({
|
|
type: "noop",
|
|
nextFailureCount: 0,
|
|
});
|
|
});
|
|
|
|
it("convergence: alternating 401/transient still triggers session-expired", () => {
|
|
// Simulate the exact scenario from #3493 CR: flapping gateway alternates
|
|
// 401 (session gone) and 503 (overloaded). With decrement-by-1, the
|
|
// counter still nets +1 per 401/transient pair and reaches threshold.
|
|
let count = 0;
|
|
const seq: Array<"unauthorized" | "transient"> = [
|
|
"unauthorized", // count -> 1
|
|
"transient", // count -> 0
|
|
"unauthorized", // count -> 1
|
|
"unauthorized", // count -> 2
|
|
"transient", // count -> 1
|
|
"unauthorized", // count -> 2
|
|
];
|
|
for (const kind of seq) {
|
|
const action = decideProbeAction(count, { kind });
|
|
expect(action.type).toBe("noop");
|
|
if (action.type === "noop") count = action.nextFailureCount;
|
|
}
|
|
// Next 401 should trip the wire (2 -> 3 == threshold).
|
|
expect(decideProbeAction(count, { kind: "unauthorized" })).toEqual({
|
|
type: "delegate-refresh",
|
|
reason: "session-expired",
|
|
});
|
|
});
|
|
});
|