Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions ui/src/lib/query-auth-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "../api/client";
import { isAuthFailure, shouldRetryQuery } from "./query-auth-policy";

describe("isAuthFailure", () => {
it("treats a 401 ApiError as an auth failure", () => {
expect(isAuthFailure(new ApiError("unauthorized", 401, null))).toBe(true);
});

it("treats a 403 ApiError as an auth failure", () => {
expect(isAuthFailure(new ApiError("forbidden", 403, null))).toBe(true);
});

it("does not treat a 500 ApiError as an auth failure", () => {
expect(isAuthFailure(new ApiError("server error", 500, null))).toBe(false);
});

it("does not treat a network error as an auth failure", () => {
expect(isAuthFailure(new TypeError("Failed to fetch"))).toBe(false);
});
});

describe("shouldRetryQuery", () => {
it("never retries a 401 — a dead session does not recover by asking again", () => {
expect(shouldRetryQuery(0, new ApiError("unauthorized", 401, null))).toBe(false);
});

it("never retries a 403", () => {
expect(shouldRetryQuery(0, new ApiError("forbidden", 403, null))).toBe(false);
});

it("retries a 500 while under the attempt budget", () => {
expect(shouldRetryQuery(0, new ApiError("server error", 500, null))).toBe(true);
expect(shouldRetryQuery(2, new ApiError("server error", 500, null))).toBe(true);
});

it("stops retrying a 500 once the attempt budget is spent", () => {
expect(shouldRetryQuery(3, new ApiError("server error", 500, null))).toBe(false);
});

it("retries a transient network error", () => {
expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true);
});
});
26 changes: 26 additions & 0 deletions ui/src/lib/query-auth-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ApiError } from "../api/client";

/** react-query's own default. Kept explicit so the policy reads in one place. */
const MAX_ATTEMPTS = 3;

/**
* A response that says "this session may not do this". Distinct from a
* transient failure: no amount of asking again turns a 401 into a 200.
*/
export function isAuthFailure(error: unknown): boolean {
return error instanceof ApiError && (error.status === 401 || error.status === 403);
}

/**
* Retry predicate for the app-wide QueryClient.
*
* Without this, an expired session makes every polling query on the page retry
* three times and then poll again on its own interval, which is how a single
* dead session turned into 50-120 requests a minute against /api/* with the
* user not even touching the page. Auth failures are terminal for the attempt;
* everything else keeps the stock budget.
*/
export function shouldRetryQuery(failureCount: number, error: unknown): boolean {
if (isAuthFailure(error)) return false;
return failureCount < MAX_ATTEMPTS;
}
120 changes: 120 additions & 0 deletions ui/src/lib/query-auth-recheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { ApiError } from "../api/client";
import { queryKeys } from "./queryKeys";
import { installAuthFailureRecheck } from "./query-auth-recheck";

function newClient(): QueryClient {
return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 30_000 } } });
}

/** Let the cache subscriber and any refetch it triggers settle. */
async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}

describe("installAuthFailureRecheck", () => {
it("re-checks the session when any other query fails with 401", async () => {
const client = newClient();
installAuthFailureRecheck(client);

let sessionFetches = 0;
await client.fetchQuery({
queryKey: queryKeys.auth.session,
queryFn: async () => {
sessionFetches += 1;
return { user: { id: "u1" } };
},
});
expect(sessionFetches).toBe(1);

await client
.fetchQuery({
queryKey: ["companies", "list"],
queryFn: async () => {
throw new ApiError("unauthorized", 401, null);
},
})
.catch(() => undefined);
await settle();

expect(sessionFetches).toBe(2);
});

it("does not re-check the session for a 500 — that is not a session problem", async () => {
const client = newClient();
installAuthFailureRecheck(client);

let sessionFetches = 0;
await client.fetchQuery({
queryKey: queryKeys.auth.session,
queryFn: async () => {
sessionFetches += 1;
return { user: { id: "u1" } };
},
});

await client
.fetchQuery({
queryKey: ["companies", "list"],
queryFn: async () => {
throw new ApiError("server error", 500, null);
},
})
.catch(() => undefined);
await settle();

expect(sessionFetches).toBe(1);
});

it("does not re-check the session in response to the session query's own failure", async () => {
const client = newClient();
installAuthFailureRecheck(client);

let sessionFetches = 0;
await client
.fetchQuery({
queryKey: queryKeys.auth.session,
queryFn: async () => {
sessionFetches += 1;
throw new ApiError("unauthorized", 401, null);
},
})
.catch(() => undefined);
await settle();

expect(sessionFetches).toBe(1);
});

it("collapses a burst of 401s from many queries into a single session re-check", async () => {
const client = newClient();
installAuthFailureRecheck(client);

let sessionFetches = 0;
await client.fetchQuery({
queryKey: queryKeys.auth.session,
queryFn: async () => {
sessionFetches += 1;
return { user: { id: "u1" } };
},
});
expect(sessionFetches).toBe(1);

await Promise.all(
["issues", "agents", "labels", "routines", "skills"].map((name) =>
client
.fetchQuery({
queryKey: [name, "list"],
queryFn: async () => {
throw new ApiError("unauthorized", 401, null);
},
})
.catch(() => undefined),
),
);
await settle();

expect(sessionFetches).toBe(2);
});
});
41 changes: 41 additions & 0 deletions ui/src/lib/query-auth-recheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { QueryClient } from "@tanstack/react-query";
import { isAuthFailure } from "./query-auth-policy";
import { queryKeys } from "./queryKeys";

const SESSION_KEY = JSON.stringify(queryKeys.auth.session);

/**
* Give CloudAccessGate a feedback path from the rest of the app.
*
* The gate decides "signed out, go to /auth/sign-in" from its own session
* query alone. That query is stale-cached, so when a session dies mid-visit
* nothing tells the gate: every other query starts returning 401 and just
* keeps polling on its interval, and the user sits in front of an app that
* quietly stops loading instead of being sent to sign-in.
*
* Any auth failure anywhere is therefore treated as a reason to re-ask the one
* question that matters. If the session really is gone the session query
* resolves null and the gate redirects once; if it is fine (a per-resource 403)
* the answer is unchanged and nothing happens.
*/
export function installAuthFailureRecheck(queryClient: QueryClient): () => void {
let recheckInFlight = false;

return queryClient.getQueryCache().subscribe((event) => {
if (event.type !== "updated") return;
if (event.query.state.status !== "error") return;
if (!isAuthFailure(event.query.state.error)) return;
// The session query answering 401 IS the answer; re-asking would loop.
if (JSON.stringify(event.query.queryKey) === SESSION_KEY) return;
// A dead session fails every query on the page at once. One re-check
// answers all of them.
if (recheckInFlight) return;

recheckInFlight = true;
void queryClient
.refetchQueries({ queryKey: queryKeys.auth.session, exact: true })
.finally(() => {
recheckInFlight = false;
});
});
}
12 changes: 12 additions & 0 deletions ui/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { initPluginBridge } from "./plugins/bridge-init";
import { PluginLauncherProvider } from "./plugins/launchers";
import { startPerfMeasureReaper } from "./lib/perf-measure-reaper";
import { shouldRetryQuery } from "./lib/query-auth-policy";
import { installAuthFailureRecheck } from "./lib/query-auth-recheck";
import { initAnalytics } from "./analytics";
import "@mdxeditor/editor/style.css";
import "./index.css";
Expand Down Expand Up @@ -47,10 +49,20 @@ const queryClient = new QueryClient({
// tuning point if we need to trim the cache footprint further.
gcTime: 5 * 60_000,
refetchOnWindowFocus: true,
// A 401/403 is an answer, not a transient failure. Without this every
// query on the page burns the full retry budget against a session that
// is already gone. See ./lib/query-auth-policy.
retry: shouldRetryQuery,
},
},
});

// ...and once a query does report an auth failure, make the access gate re-ask
// whether we are still signed in, so a session that dies mid-visit sends the
// user to sign-in instead of silently breaking the page. See
// ./lib/query-auth-recheck.
installAuthFailureRecheck(queryClient);

function CompanyAwareBreadcrumbProvider({ children }: { children: React.ReactNode }) {
const { selectedCompany } = useCompany();
return <BreadcrumbProvider companyName={selectedCompany?.name ?? null}>{children}</BreadcrumbProvider>;
Expand Down
Loading