Compare commits

...

4 Commits

Author SHA1 Message Date
default 0642f7cf87 fix: apply biome formatting 2026-03-18 22:16:21 +00:00
default df6e15c122 fix: exclude 401 from permissions error state, extract retry helper
A 401 on the permissions query should redirect to login, not show
the connection error screen. Also extracts the retry configuration
into shared helpers to reduce duplication.
2026-03-18 21:40:58 +00:00
default 94384cf75e fix: also catch permissions query failures in isError state 2026-03-18 21:23:57 +00:00
default 555004e9fd fix: handle non-401 auth errors gracefully instead of crashing
When the user API call fails with a non-401 error (network timeout, 500,
502), the app now shows a recoverable error screen with a retry option
instead of crashing to the full error boundary.

Also adds retry logic (up to 3 attempts with exponential backoff) for
critical auth queries, so transient network failures are retried
automatically. 401 errors are excluded from retry since they indicate
the user is genuinely not authenticated.

Fixes #17780
2026-03-18 21:23:57 +00:00
4 changed files with 125 additions and 6 deletions
@@ -2,7 +2,7 @@ import { Button } from "components/Button/Button";
import { CoderIcon } from "components/Icons/CoderIcon";
import { Link } from "components/Link/Link";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { type FC, useState } from "react";
import { type FC, useEffect, useState } from "react";
import {
type ErrorResponse,
isRouteErrorResponse,
@@ -27,6 +27,10 @@ export const GlobalErrorBoundaryInner: FC<GlobalErrorBoundaryInnerProps> = ({
const { metadata } = useEmbeddedMetadata();
const location = useLocation();
useEffect(() => {
console.error("[GlobalErrorBoundary] Uncaught error:", error);
}, [error]);
const coderVersion = metadata["build-info"].value?.version;
const isRenderableError =
error instanceof Error || isRouteErrorResponse(error);
+31 -1
View File
@@ -28,6 +28,7 @@ export type AuthContextValue = {
isSignedIn: boolean;
isSigningIn: boolean;
isUpdatingProfile: boolean;
isError: boolean;
user: User | undefined;
permissions: Permissions | undefined;
signInError: unknown;
@@ -41,12 +42,26 @@ export const AuthContext = createContext<AuthContextValue | undefined>(
undefined,
);
// Don't retry 401s — the user is genuinely not authenticated.
const shouldRetryAuth = (failureCount: number, error: unknown): boolean => {
if (isApiError(error) && error.response.status === 401) {
return false;
}
return failureCount < 3;
};
const authRetryDelay = (attempt: number) =>
Math.min(1000 * 2 ** attempt, 10000);
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
const { metadata } = useEmbeddedMetadata();
const userMetadataState = metadata.user;
const meOptions = me(userMetadataState);
const userQuery = useQuery(meOptions);
const userQuery = useQuery({
...meOptions,
retry: shouldRetryAuth,
retryDelay: authRetryDelay,
});
const hasFirstUserQuery = useQuery(hasFirstUser(userMetadataState));
const permissionsQuery = useQuery({
@@ -55,6 +70,8 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
metadata.permissions,
),
enabled: userQuery.data !== undefined,
retry: shouldRetryAuth,
retryDelay: authRetryDelay,
});
const queryClient = useQueryClient();
@@ -84,6 +101,18 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
const isSignedIn = userQuery.isSuccess && userQuery.data !== undefined;
const isSigningIn = loginMutation.isPending;
const isUpdatingProfile = updateProfileMutation.isPending;
// Non-401 errors from the user query (e.g. network timeout, 500,
// 502) represent a transient failure, not a sign-out. Exposing
// this lets RequireAuth show a recoverable error screen instead
// of crashing through to the error boundary.
const isError =
(userQuery.isError && !isSignedOut) ||
(userQuery.isSuccess &&
permissionsQuery.isError &&
!(
isApiError(permissionsQuery.error) &&
permissionsQuery.error.response.status === 401
));
const signOut = useCallback(() => {
logoutMutation.mutate();
@@ -118,6 +147,7 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
isSignedIn,
isSigningIn,
isUpdatingProfile,
isError,
signOut,
signIn,
updateProfile,
+48 -3
View File
@@ -4,12 +4,14 @@ import {
renderWithAuth,
} from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { renderHook, screen } from "@testing-library/react";
import { render, renderHook, screen } from "@testing-library/react";
import { useAuthenticated } from "hooks";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { QueryClientProvider } from "react-query";
import { MemoryRouter, Route, Routes } from "react-router";
import { AuthContext, type AuthContextValue } from "./AuthProvider";
import { RequireAuth } from "./RequireAuth";
describe("RequireAuth", () => {
it("redirects to /login if user is not authenticated", async () => {
@@ -31,6 +33,50 @@ describe("RequireAuth", () => {
await screen.findByText("Login");
});
it("shows a recoverable error screen on non-401 API errors", () => {
// Directly mock the auth context with isError=true to simulate
// a non-401 failure (e.g. 500, network timeout) without relying
// on real query retry timing.
const authValue: AuthContextValue = {
user: undefined,
isLoading: false,
isSignedOut: false,
isSigningOut: false,
isConfiguringTheFirstUser: false,
isSignedIn: false,
isSigningIn: false,
isUpdatingProfile: false,
isError: true,
permissions: undefined,
signInError: undefined,
updateProfileError: undefined,
signOut: vi.fn(),
signIn: vi.fn(),
updateProfile: vi.fn(),
};
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<Routes>
<Route element={<RequireAuth />}>
<Route path="/" element={<h1>Dashboard</h1>} />
</Route>
</Routes>
</MemoryRouter>
</AuthContext.Provider>
</QueryClientProvider>,
);
// Should show the connection error screen, not the dashboard
// or the global error boundary.
expect(screen.getByText("Unable to connect")).toBeDefined();
expect(screen.getByTestId("retry-button")).toBeDefined();
});
});
const createAuthWrapper = (override: Partial<AuthContextValue>) => {
@@ -43,9 +89,8 @@ const createAuthWrapper = (override: Partial<AuthContextValue>) => {
isSignedIn: false,
isSigningIn: false,
isUpdatingProfile: false,
isError: false,
permissions: undefined,
authMethods: undefined,
organizationIds: undefined,
signInError: undefined,
updateProfileError: undefined,
signOut: vi.fn(),
+41 -1
View File
@@ -1,5 +1,7 @@
import { API } from "api/api";
import { isApiError } from "api/errors";
import { Button } from "components/Button/Button";
import { CoderIcon } from "components/Icons/CoderIcon";
import { Loader } from "components/Loader/Loader";
import { ProxyProvider as ProductionProxyProvider } from "contexts/ProxyContext";
import { DashboardProvider as ProductionDashboardProvider } from "modules/dashboard/DashboardProvider";
@@ -26,7 +28,7 @@ export const RequireAuth: FC<RequireAuthProps> = ({
ProxyProvider = ProductionProxyProvider,
}) => {
const location = useLocation();
const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading } =
const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading, isError } =
useAuthContext();
useEffect(() => {
@@ -71,6 +73,10 @@ export const RequireAuth: FC<RequireAuthProps> = ({
);
}
if (isError) {
return <ConnectionErrorScreen />;
}
// Authenticated pages have access to some contexts for knowing enabled experiments
// and where to route workspace connections.
return (
@@ -81,3 +87,37 @@ export const RequireAuth: FC<RequireAuthProps> = ({
</DashboardProvider>
);
};
/**
* Full-screen error shown when the user API call fails with a non-401
* error (network timeout, 500, 502, etc.). Gives the user a simple
* retry action instead of crashing to the global error boundary.
*/
const ConnectionErrorScreen: FC = () => {
return (
<div className="bg-surface-primary text-center w-full h-full flex justify-center items-center absolute inset-0">
<main className="flex gap-6 w-full max-w-prose p-4 flex-col flex-nowrap">
<div className="flex gap-2 flex-col items-center">
<CoderIcon className="w-11 h-11" />
<div className="text-content-primary flex flex-col gap-1">
<h1 className="text-2xl font-semibold m-0">Unable to connect</h1>
<p className="leading-6 m-0 text-content-secondary text-sm">
We&apos;re having trouble reaching the server. This may be a
temporary network issue.
</p>
</div>
</div>
<div className="flex flex-row flex-nowrap justify-center gap-2">
<Button
className="min-w-32"
onClick={() => window.location.reload()}
data-testid="retry-button"
>
Retry
</Button>
</div>
</main>
</div>
);
};