Skip to content

Commit a0cd5a5

Browse files
committed
feat(webapp,clickhouse): return an actionable error instead of a 500 when a runs list query is too expensive
When a runs list query exceeds ClickHouse resource limits, surface a clear error asking the user to narrow their time range, and stop retrying it. The public runs.list API now returns 422 with a helpful message and an x-should-retry: false header, so the failure is non-fatal for tasks. The dashboard runs list shows the same guidance instead of a generic error page.
1 parent 11e1cd8 commit a0cd5a5

15 files changed

Lines changed: 198 additions & 49 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
When a runs list or runs.list API request spans too much data to complete, it now returns a clear, actionable error asking you to narrow the time range, instead of failing with a generic error.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Callout } from "~/components/primitives/Callout";
2+
3+
/**
4+
* Error state for a runs list that failed to load. Shown as the `errorElement` of the deferred
5+
* runs-list data. The most common recoverable cause is a query that was too expensive over a broad
6+
* time range (see `RunsListQueryError`), so the copy guides narrowing the range; a refresh covers
7+
* transient failures. The precise reason is not shown because Remix scrubs thrown error messages in
8+
* production.
9+
*/
10+
export function RunsListErrorState() {
11+
return (
12+
<div className="flex items-center justify-center px-3 py-12">
13+
<Callout variant="error" className="max-w-fit">
14+
We couldn't load these runs. If you're filtering over a broad time range, try narrowing it,
15+
then refresh to try again.
16+
</Callout>
17+
</div>
18+
);
19+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
2222
import * as Property from "~/components/primitives/PropertyTable";
2323
import { Spinner } from "~/components/primitives/Spinner";
2424
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
25+
import { RunsListErrorState } from "~/components/runs/v3/RunsListErrorState";
2526
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
2627
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
2728
import { SessionsTable } from "~/components/sessions/v1/SessionsTable";
@@ -395,7 +396,7 @@ function AgentContentArea({
395396
</Suspense>
396397
) : (
397398
<Suspense fallback={<TableLoading />}>
398-
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
399+
<TypedAwait resolve={runList} errorElement={<RunsListErrorState />}>
399400
{(list) =>
400401
list ? (
401402
<TaskRunsTable

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

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { Spinner } from "~/components/primitives/Spinner";
5555
import { useToast } from "~/components/primitives/Toast";
5656
import TooltipPortal from "~/components/primitives/TooltipPortal";
5757
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
58+
import { RunsListErrorState } from "~/components/runs/v3/RunsListErrorState";
5859
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
5960
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
6061
import { $replica } from "~/db.server";
@@ -393,16 +394,7 @@ export default function Page() {
393394
</div>
394395
}
395396
>
396-
<TypedAwait
397-
resolve={data}
398-
errorElement={
399-
<div className="flex items-center justify-center px-3 py-12">
400-
<Callout variant="error" className="max-w-fit">
401-
Unable to load error details. Please refresh the page or try again in a moment.
402-
</Callout>
403-
</div>
404-
}
405-
>
397+
<TypedAwait resolve={data} errorElement={<RunsListErrorState />}>
406398
{(result) => {
407399
if ("error" in result) {
408400
return (

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

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import {
7474
import { throwNotFound } from "~/utils/httpErrors";
7575
import { ListPagination } from "../../components/ListPagination";
7676
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
77-
import { Callout } from "~/components/primitives/Callout";
77+
import { RunsListErrorState } from "~/components/runs/v3/RunsListErrorState";
7878
import {
7979
isRunsListLoading,
8080
RUNS_BULK_INSPECTOR_OPEN_VALUE,
@@ -208,17 +208,7 @@ export default function Page() {
208208
</div>
209209
}
210210
>
211-
<TypedAwait
212-
resolve={data}
213-
errorElement={
214-
<div className="flex items-center justify-center px-3 py-12">
215-
<Callout variant="error" className="max-w-fit">
216-
Unable to load your task runs. Please refresh the page or try again in a
217-
moment.
218-
</Callout>
219-
</div>
220-
}
221-
>
211+
<TypedAwait resolve={data} errorElement={<RunsListErrorState />}>
222212
{(list) => {
223213
return (
224214
<RunsList

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
6161
import { useToast } from "~/components/primitives/Toast";
6262
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
63+
import { RunsListErrorState } from "~/components/runs/v3/RunsListErrorState";
6364
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
6465
import { ScheduleTypeIcon, scheduleTypeName } from "~/components/runs/v3/ScheduleType";
6566
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
@@ -382,7 +383,7 @@ export default function Page() {
382383
</TitleBar>
383384
<div className="min-h-0 overflow-hidden">
384385
<Suspense fallback={<TableLoading />}>
385-
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
386+
<TypedAwait resolve={runList} errorElement={<RunsListErrorState />}>
386387
{(list) =>
387388
list ? (
388389
<TaskRunsList

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from "~/components/primitives/Resizable";
3131
import { Spinner } from "~/components/primitives/Spinner";
3232
import { TextLink } from "~/components/primitives/TextLink";
33+
import { RunsListErrorState } from "~/components/runs/v3/RunsListErrorState";
3334
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
3435
import {
3536
QUEUE_METRIC_COLORS,
@@ -278,7 +279,7 @@ export default function Page() {
278279
</TitleBar>
279280
<div className="min-h-0 overflow-hidden">
280281
<Suspense fallback={<TableLoading />}>
281-
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
282+
<TypedAwait resolve={runList} errorElement={<RunsListErrorState />}>
282283
{(list) =>
283284
list ? (
284285
<TaskRunsList

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { PulsingDot } from "~/components/primitives/PulsingDot";
3030
import { Spinner } from "~/components/primitives/Spinner";
3131
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
32+
import { RunsListErrorState } from "~/components/runs/v3/RunsListErrorState";
3233
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
3334
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
3435
import { DeliveriesTable } from "~/components/webhookDeliveries/v1/DeliveriesTable";
@@ -329,7 +330,7 @@ export default function Page() {
329330
</Suspense>
330331
) : (
331332
<Suspense fallback={null}>
332-
<TypedAwait resolve={runList} errorElement={null}>
333+
<TypedAwait resolve={runList} errorElement={<RunsListErrorState />}>
333334
{(list) =>
334335
list ? (
335336
<ListPagination

apps/webapp/app/routes/api.v1.runs.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createLoaderApiRoute,
99
everyResource,
1010
} from "~/services/routeBuilders/apiBuilder.server";
11+
import { RunsListQueryError } from "~/services/runsRepository/runsRepository.server";
1112

1213
export const loader = createLoaderApiRoute(
1314
{
@@ -40,13 +41,23 @@ export const loader = createLoaderApiRoute(
4041
},
4142
async ({ searchParams, authentication, apiVersion }) => {
4243
const presenter = new ApiRunListPresenter();
43-
const result = await presenter.call(
44-
authentication.environment.project,
45-
searchParams,
46-
apiVersion,
47-
authentication.environment
48-
);
44+
try {
45+
const result = await presenter.call(
46+
authentication.environment.project,
47+
searchParams,
48+
apiVersion,
49+
authentication.environment
50+
);
4951

50-
return json(result);
52+
return json(result);
53+
} catch (error) {
54+
if (error instanceof RunsListQueryError) {
55+
return json(
56+
{ error: error.message },
57+
{ status: error.status, headers: { "x-should-retry": "false" } }
58+
);
59+
}
60+
throw error;
61+
}
5162
}
5263
);

apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ClickhouseQueryBuilder } from "@internal/clickhouse";
1+
import { type ClickhouseQueryBuilder, isClickhouseResourceLimitError } from "@internal/clickhouse";
22
import { ErrorId, RunId } from "@trigger.dev/core/v3/isomorphic";
33
import {
44
type FilterRunsOptions,
@@ -10,6 +10,7 @@ import {
1010
type RunsRepositoryOptions,
1111
type TagListOptions,
1212
convertRunListInputOptionsToFilterRunsOptions,
13+
RunsListQueryError,
1314
} from "./runsRepository.server";
1415
import parseDuration from "parse-duration";
1516
import { decodeRunsCursor, encodeRunsCursor } from "./runsCursor.server";
@@ -19,6 +20,18 @@ import { type PrismaClientOrTransaction } from "~/db.server";
1920
import { boundedIn, type Prisma } from "@trigger.dev/database";
2021
type RunCursorRow = { runId: string; createdAt: number };
2122

23+
/**
24+
* Re-throws a runs-list query error, converting a ClickHouse resource-limit rejection (execution
25+
* time or memory) into a typed {@link RunsListQueryError} so callers can surface an actionable 4xx
26+
* instead of an opaque 500. Any other error is re-thrown unchanged.
27+
*/
28+
function rethrowRunsListQueryError(queryError: unknown): never {
29+
if (isClickhouseResourceLimitError(queryError)) {
30+
throw new RunsListQueryError(undefined, { cause: queryError });
31+
}
32+
throw queryError;
33+
}
34+
2235
/**
2336
* Default hydrate select for the runs list, used when a caller does not derive
2437
* one from the visible columns (bulk actions, the live poll). Kept in sync with
@@ -102,7 +115,7 @@ export class ClickHouseRunsRepository implements IRunsRepository {
102115
const [queryError, result] = await queryBuilder.execute();
103116

104117
if (queryError) {
105-
throw queryError;
118+
rethrowRunsListQueryError(queryError);
106119
}
107120

108121
return (result?.length ?? 0) > 0;
@@ -166,7 +179,7 @@ export class ClickHouseRunsRepository implements IRunsRepository {
166179
const [queryError, result] = await queryBuilder.execute();
167180

168181
if (queryError) {
169-
throw queryError;
182+
rethrowRunsListQueryError(queryError);
170183
}
171184

172185
return result.map((row) => ({ runId: row.run_id, createdAt: row.created_at_ms }));
@@ -349,7 +362,7 @@ export class ClickHouseRunsRepository implements IRunsRepository {
349362
const [queryError, result] = await queryBuilder.execute();
350363

351364
if (queryError) {
352-
throw queryError;
365+
rethrowRunsListQueryError(queryError);
353366
}
354367

355368
if (result.length === 0) {
@@ -402,7 +415,7 @@ export class ClickHouseRunsRepository implements IRunsRepository {
402415
const [queryError, result] = await queryBuilder.execute();
403416

404417
if (queryError) {
405-
throw queryError;
418+
rethrowRunsListQueryError(queryError);
406419
}
407420

408421
return {

0 commit comments

Comments
 (0)