Skip to content

Commit 6b46a34

Browse files
d-csclaude
andauthored
fix(webapp): return 404 instead of 500 for missing env/project/schedule loaders (#3663)
## Summary - Dashboard loaders for runs / sessions / batches / schedule-detail threw bare `Error("X not found")` when a slug didn't resolve. Remix surfaces this as a 500 and Sentry captures it via auto-instrumentation, producing ongoing noise from real users following stale preview-branch or deleted-resource links (the URLs in those Sentry events all carry `?_data=routes/...`, i.e. client-side revalidation, not full-page navigation). - Added a `throwNotFound(statusText)` helper in `app/utils/httpErrors.ts` that throws a Response with status 404, matching the established pattern in sibling routes (agents, alerts, bulk-actions, etc.). - Migrated 5 loader sites to `throwNotFound` (4× "Environment not found", 1× "Schedule not found"). - Migrated 1 loader site (`runs._index` project branch) to `redirectWithErrorMessage("/", request, "Project not found")` to match the pre-existing convention used by every other dashboard route's project-not-found branch. - Intentionally **not** touched: bare `throw new Error("X not found")` inside `resources.*` action routes (sit inside try/catch blocks that already redirect with a flash message), the invariant assertion in `vercel.connect.tsx`, and the admin config check in `admin.api.v1.runs-replication.backfill.ts`. ## Where the fix is visible Normal browser navigation to these URLs doesn't reach the buggy loaders — the parent env-layout (`_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx`) already filters missing envs/projects and redirects/404s before the child loader runs. The bug fires exclusively when Remix calls a single child loader via `?_data=routes/...`, which happens during client-side navigation or `useRevalidator`. That matches every Sentry event URL. ## Test plan - [x] Unit test for the new helper — `apps/webapp/test/httpErrors.test.ts` - [x] `pnpm run typecheck --filter webapp` clean - [x] Manual verification via Playwright on `main` vs this branch (6 cases): main returns 500 for each defective `_data` URL; branch returns 404 or 204 + `X-Remix-Redirect` as designed - [x] Verified user-visible 404 catch boundary on `schedules/<missing>` (the one case reachable via normal nav) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f91b96e commit 6b46a34

8 files changed

Lines changed: 50 additions & 6 deletions

File tree

  • .server-changes
  • apps/webapp
    • app
      • routes
        • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches
        • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index
        • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam
        • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam
        • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index
      • utils
    • test
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Dashboard runs, sessions, batches, and schedule-detail loaders now return 404 (or redirect to the user's home with a toast for missing projects) instead of 500 when a slug doesn't resolve.

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
v3BatchPath,
5555
v3BatchRunsPath,
5656
} from "~/utils/pathBuilder";
57+
import { throwNotFound } from "~/utils/httpErrors";
5758

5859
export const meta: MetaFunction = () => {
5960
return [
@@ -74,7 +75,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
7475

7576
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
7677
if (!environment) {
77-
throw new Error("Environment not found");
78+
throwNotFound("Environment not found");
7879
}
7980

8081
const url = new URL(request.url);

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
4040
import { useProject } from "~/hooks/useProject";
4141
import { useSearchParams } from "~/hooks/useSearchParam";
4242
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
43+
import { redirectWithErrorMessage } from "~/models/message.server";
4344
import { findProjectBySlug } from "~/models/project.server";
4445
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
4546
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
@@ -59,6 +60,7 @@ import {
5960
v3TestPath,
6061
v3TestTaskPath,
6162
} from "~/utils/pathBuilder";
63+
import { throwNotFound } from "~/utils/httpErrors";
6264
import { ListPagination } from "../../components/ListPagination";
6365
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
6466
import { Callout } from "~/components/primitives/Callout";
@@ -77,12 +79,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
7779

7880
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
7981
if (!project) {
80-
throw new Error("Project not found");
82+
return redirectWithErrorMessage("/", request, "Project not found");
8183
}
8284

8385
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
8486
if (!environment) {
85-
throw new Error("Environment not found");
87+
throwNotFound("Environment not found");
8688
}
8789

8890
const filters = await getRunFiltersFromRequest(request);

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
v3SchedulePath,
5656
v3SchedulesPath,
5757
} from "~/utils/pathBuilder";
58+
import { throwNotFound } from "~/utils/httpErrors";
5859
import { DeleteTaskScheduleService } from "~/v3/services/deleteTaskSchedule.server";
5960
import { SetActiveOnTaskScheduleService } from "~/v3/services/setActiveOnTaskSchedule.server";
6061

@@ -84,7 +85,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8485
});
8586

8687
if (!result) {
87-
throw new Error("Schedule not found");
88+
throwNotFound("Schedule not found");
8889
}
8990

9091
return typedjson({ schedule: result.schedule });

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
v3RunsPath,
5252
v3SessionsPath,
5353
} from "~/utils/pathBuilder";
54+
import { throwNotFound } from "~/utils/httpErrors";
5455

5556
const ParamsSchema = EnvironmentParamSchema.extend({
5657
sessionParam: z.string(),
@@ -71,7 +72,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
7172

7273
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
7374
if (!environment) {
74-
throw new Error("Environment not found");
75+
throwNotFound("Environment not found");
7576
}
7677

7778
const presenter = new SessionPresenter($replica);

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.serve
1919
import { clickhouseClient } from "~/services/clickhouseInstance.server";
2020
import { requireUserId } from "~/services/session.server";
2121
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
22+
import { throwNotFound } from "~/utils/httpErrors";
2223

2324
export const meta: MetaFunction = () => {
2425
return [
@@ -39,7 +40,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
3940

4041
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
4142
if (!environment) {
42-
throw new Error("Environment not found");
43+
throwNotFound("Environment not found");
4344
}
4445

4546
const filters = getSessionFiltersFromRequest(request);

apps/webapp/app/utils/httpErrors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
export function throwNotFound(statusText: string): never {
2+
throw new Response(undefined, { status: 404, statusText });
3+
}
4+
15
export function friendlyErrorDisplay(statusCode: number, statusText?: string) {
26
switch (statusCode) {
37
case 400:
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
import { throwNotFound } from "~/utils/httpErrors";
3+
4+
describe("throwNotFound", () => {
5+
it("throws a Response with status 404 and the provided statusText", () => {
6+
let thrown: unknown;
7+
try {
8+
throwNotFound("Environment not found");
9+
} catch (e) {
10+
thrown = e;
11+
}
12+
13+
expect(thrown).toBeInstanceOf(Response);
14+
expect((thrown as Response).status).toBe(404);
15+
expect((thrown as Response).statusText).toBe("Environment not found");
16+
});
17+
18+
it("passes through whatever statusText the caller provides", () => {
19+
let thrown: unknown;
20+
try {
21+
throwNotFound("Project not found");
22+
} catch (e) {
23+
thrown = e;
24+
}
25+
26+
expect((thrown as Response).statusText).toBe("Project not found");
27+
});
28+
});

0 commit comments

Comments
 (0)