Skip to content

Commit f4916fb

Browse files
committed
fix(webapp): capture Prisma infrastructure errors and stop leaking their messages
Prisma infrastructure failures (P1xxx-class: DB unreachable/timed out/connection dropped, engine init/panic) carry the database hostname in their message. Capture them centrally and ensure they never reach API clients verbatim. - db.server.ts: a $allOperations extension on the writer and replica clients logs infra errors with the model/operation, then rethrows the ORIGINAL error so the ~40 call sites that branch on error.code (and transaction retries) keep working. - transaction boundary: log infra errors that surface from $transaction() without a Prisma code (e.g. PrismaClientInitializationError), which the existing coded- error callback misses. - clientSafeErrorMessage(): swap an infra error's message for "Internal Server Error" at the API routes that returned it raw, leaving status codes, headers, and all non-infra messages unchanged. Applied to the batch trigger routes, schedule delete, and the worker continue action. Adds testcontainer + real-error-instance tests covering message obfuscation, pass-through of P2xxx codes, transaction-interior firing, and the boundary path.
1 parent 530b388 commit f4916fb

9 files changed

Lines changed: 311 additions & 8 deletions
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+
Log Prisma infrastructure errors (P1xxx) centrally and obfuscate their messages (which carry the DB hostname) on API responses that previously returned the raw message, without changing status codes or headers.

apps/webapp/app/db.server.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { z } from "zod";
1212
import { env } from "./env.server";
1313
import { logger } from "./services/logger.server";
1414
import { isValidDatabaseUrl } from "./utils/db";
15+
import {
16+
captureInfrastructureErrors,
17+
logTransactionInfrastructureError,
18+
} from "./utils/prismaErrors";
1519
import { singleton } from "./utils/singleton";
1620
import { DATASOURCE_CONTEXT_KEY, startActiveSpan } from "./v3/tracer.server";
1721
import { context, Span, trace } from "@opentelemetry/api";
@@ -40,6 +44,22 @@ export async function $transaction<R>(
4044
fnOrName: ((prisma: PrismaTransactionClient) => Promise<R>) | string,
4145
fnOrOptions?: ((prisma: PrismaTransactionClient) => Promise<R>) | PrismaTransactionOptions,
4246
options?: PrismaTransactionOptions
47+
): Promise<R | undefined> {
48+
try {
49+
return await $transactionInner(prisma, fnOrName, fnOrOptions, options);
50+
} catch (error) {
51+
// transac()'s callback only logs coded Prisma errors; infra errors such as
52+
// PrismaClientInitializationError reach the boundary without a `.code`.
53+
logTransactionInfrastructureError(error);
54+
throw error;
55+
}
56+
}
57+
58+
async function $transactionInner<R>(
59+
prisma: PrismaClientOrTransaction,
60+
fnOrName: ((prisma: PrismaTransactionClient) => Promise<R>) | string,
61+
fnOrOptions?: ((prisma: PrismaTransactionClient) => Promise<R>) | PrismaTransactionOptions,
62+
options?: PrismaTransactionOptions
4363
): Promise<R | undefined> {
4464
if (typeof fnOrName === "string") {
4565
return await startActiveSpan(fnOrName, async (span) => {
@@ -116,11 +136,13 @@ function tagDatasource<T extends PrismaClient>(
116136
}) as unknown as T;
117137
}
118138

119-
export const prisma = singleton("prisma", () => tagDatasource("writer", getClient()));
139+
export const prisma = singleton("prisma", () =>
140+
captureInfrastructureErrors(tagDatasource("writer", getClient()))
141+
);
120142

121143
export const $replica: PrismaReplicaClient = singleton("replica", () => {
122144
const replica = getReplicaClient();
123-
return replica ? tagDatasource("replica", replica) : prisma;
145+
return replica ? captureInfrastructureErrors(tagDatasource("replica", replica)) : prisma;
124146
});
125147

126148
function getClient() {

apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime";
33
import { ScheduleObject, UpdateScheduleOptions } from "@trigger.dev/core/v3";
44
import { z } from "zod";
55
import { Prisma, prisma } from "~/db.server";
6+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
67
import { scheduleUniqWhereClause } from "~/models/schedules.server";
78
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
89
import { authenticateApiRequest } from "~/services/apiAuth.server";
@@ -54,7 +55,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
5455
// Check if it's a Prisma error
5556
if (error instanceof Prisma.PrismaClientKnownRequestError) {
5657
return json(
57-
{ error: error.code === "P2025" ? "Schedule not found" : error.message },
58+
{ error: error.code === "P2025" ? "Schedule not found" : clientSafeErrorMessage(error) },
5859
{ status: error.code === "P2025" ? 404 : 422 }
5960
);
6061
} else {

apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BatchTriggerTaskRequestBody, BatchTriggerTaskV2RequestBody } from "@tri
44
import { z } from "zod";
55
import { fromZodError } from "zod-validation-error";
66
import { MAX_BATCH_TRIGGER_ITEMS } from "~/consts";
7+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
78
import { env } from "~/env.server";
89
import { authenticateApiRequest } from "~/services/apiAuth.server";
910
import { logger } from "~/services/logger.server";
@@ -125,7 +126,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
125126
);
126127
} catch (error) {
127128
if (error instanceof Error) {
128-
return json({ error: error.message }, { status: 400 });
129+
return json({ error: clientSafeErrorMessage(error) }, { status: 400 });
129130
}
130131

131132
return json({ error: "Something went wrong" }, { status: 500 });

apps/webapp/app/routes/api.v2.tasks.batch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ServiceValidationError } from "~/v3/services/baseService.server";
2121
import { BatchProcessingStrategy } from "~/v3/services/batchTriggerV3.server";
2222
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
2323
import { sanitizeTriggerSource } from "~/utils/triggerSource";
24+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
2425
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
2526
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
2627
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
@@ -175,7 +176,7 @@ const { action, loader } = createActionApiRoute(
175176

176177
if (error instanceof Error) {
177178
return json(
178-
{ error: error.message },
179+
{ error: clientSafeErrorMessage(error) },
179180
{ status: 500, headers: { "x-should-retry": "false" } }
180181
);
181182
}

apps/webapp/app/routes/api.v3.batches.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { ServiceValidationError } from "~/v3/services/baseService.server";
1515
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
1616
import { sanitizeTriggerSource } from "~/utils/triggerSource";
17+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
1718
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
1819
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
1920
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
@@ -190,7 +191,7 @@ const { action, loader } = createActionApiRoute(
190191

191192
if (error instanceof Error) {
192193
return json(
193-
{ error: error.message },
194+
{ error: clientSafeErrorMessage(error) },
194195
{ status: 500, headers: { "x-should-retry": "false" } }
195196
);
196197
}

apps/webapp/app/routes/engine.v1.worker-actions.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.continue.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { WorkerApiContinueRunExecutionRequestBody } from "@trigger.dev/core/v3/w
33
import { z } from "zod";
44
import { logger } from "~/services/logger.server";
55
import { createLoaderWorkerApiRoute } from "~/services/routeBuilders/apiBuilder.server";
6+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
67

78
export const loader = createLoaderWorkerApiRoute(
89
{
@@ -31,7 +32,7 @@ export const loader = createLoaderWorkerApiRoute(
3132
} catch (error) {
3233
logger.warn("Failed to suspend run", { runFriendlyId, snapshotFriendlyId, error });
3334
if (error instanceof Error) {
34-
throw json({ error: error.message }, { status: 422 });
35+
throw json({ error: clientSafeErrorMessage(error) }, { status: 422 });
3536
}
3637

3738
throw json({ error: "Failed to continue run execution" }, { status: 422 });

apps/webapp/app/utils/prismaErrors.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { Prisma } from "@trigger.dev/database";
1+
import { Prisma, type PrismaClient, isPrismaKnownError } from "@trigger.dev/database";
2+
import { logger } from "~/services/logger.server";
3+
4+
// Minimal structural logger so this stays decoupled from the concrete Logger
5+
// (and lets tests pass a capturing logger).
6+
type ErrorLogger = { error: (message: string, fields?: Record<string, unknown>) => void };
27

38
// Prisma connectivity / infrastructure error codes — engine- and
49
// connection-level failures, not query- or validation-level ones. When the
@@ -37,3 +42,63 @@ export function isInfrastructureError(error: unknown): boolean {
3742

3843
return false;
3944
}
45+
46+
// Logs infrastructure failures (P1xxx-class, see isInfrastructureError) and
47+
// rethrows the ORIGINAL error: callers branch on error.code, and this fires
48+
// per-statement inside transactions, so converting it would break that.
49+
export function captureInfrastructureErrors<T extends PrismaClient>(
50+
client: T,
51+
log: ErrorLogger = logger
52+
): T {
53+
return client.$extends({
54+
name: "infrastructure-error-capture",
55+
query: {
56+
$allOperations: async ({ model, operation, args, query }) => {
57+
try {
58+
return await query(args);
59+
} catch (error) {
60+
if (isInfrastructureError(error)) {
61+
log.error("prisma infrastructure error", {
62+
model,
63+
operation,
64+
code: error instanceof Prisma.PrismaClientKnownRequestError ? error.code : undefined,
65+
meta: error instanceof Prisma.PrismaClientKnownRequestError ? error.meta : undefined,
66+
message: error instanceof Error ? error.message : String(error),
67+
stack: error instanceof Error ? error.stack : undefined,
68+
});
69+
}
70+
71+
throw error;
72+
}
73+
},
74+
},
75+
}) as unknown as T;
76+
}
77+
78+
// Logs infrastructure errors that reach the $transaction boundary WITHOUT a
79+
// Prisma error code (e.g. PrismaClientInitializationError). Coded errors are
80+
// already logged by transac()'s callback, so they are skipped here to avoid
81+
// double-logging. Returns whether it logged.
82+
export function logTransactionInfrastructureError(
83+
error: unknown,
84+
log: ErrorLogger = logger
85+
): boolean {
86+
if (!isInfrastructureError(error) || isPrismaKnownError(error)) {
87+
return false;
88+
}
89+
90+
log.error("prisma.$transaction infrastructure error", {
91+
message: error instanceof Error ? error.message : String(error),
92+
name: error instanceof Error ? error.name : undefined,
93+
stack: error instanceof Error ? error.stack : undefined,
94+
});
95+
96+
return true;
97+
}
98+
99+
// Replaces a Prisma infrastructure error's message (which carries the DB
100+
// hostname) with a generic one before it reaches an API client. Any other
101+
// error's message is returned unchanged. Status codes/headers are unaffected.
102+
export function clientSafeErrorMessage(error: Error): string {
103+
return isInfrastructureError(error) ? "Internal Server Error" : error.message;
104+
}

0 commit comments

Comments
 (0)