Skip to content

Commit 901accb

Browse files
committed
Shape storage error messages and classify connection faults
1 parent 4c9e755 commit 901accb

6 files changed

Lines changed: 414 additions & 14 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@executor-js/sdk": patch
3+
"@executor-js/api": patch
4+
---
5+
6+
Build `StorageError.message` from the call-site label plus the driver's error code instead of the driver's raw text. The driver text is drizzle's `Failed query: <sql>\nparams: <bound values>`, so error reporting grouped one storage defect by statement shape and printed bound parameters into issue titles. The full driver error stays on `cause`.
7+
8+
Add `StorageConnectionError`, a `StorageFailure` variant for postgres.js connection faults (`CONNECTION_ENDED`, `CONNECTION_CLOSED`, `CONNECTION_DESTROYED`, `CONNECT_TIMEOUT`, `ECONNRESET`) and workerd's cross-request I/O rejection. It carries the fault `code` and a `retryable` flag so a lost socket can be told apart from a pool-lifetime bug.

packages/core/api/src/observability.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
// the cloud Worker, console in the CLI, in-memory in tests) to
1414
// record causes and return correlation ids. Optional; absent →
1515
// empty trace ids, nothing breaks.
16-
// 3. `capture(eff)` — the one translator. Catches `StorageError` and
17-
// `UniqueViolationError` in the typed channel: the former is
16+
// 3. `capture(eff)` — the one translator. Catches `StorageError`,
17+
// `StorageConnectionError` and `UniqueViolationError` in the typed
18+
// channel: the storage failures are
1819
// captured via `ErrorCapture` and re-failed as `InternalError({
1920
// traceId })`; the latter dies as a defect (plugins that want to
2021
// surface it as a typed domain error should `Effect.catchTag`
@@ -75,6 +76,10 @@ const resolveCapture = Effect.serviceOption(ErrorCapture).pipe(
7576
*
7677
* - `StorageError` — known backend failure. Capture the cause via
7778
* `ErrorCapture`, fail with `InternalError({ traceId })`.
79+
* - `StorageConnectionError` — the database connection failed, so the
80+
* statement never got a verdict. Same edge treatment as
81+
* `StorageError` (capture, opaque 500); the tag exists so callers
82+
* that can retry on a fresh pool are able to tell the two apart.
7883
* - `UniqueViolationError` — invariant violation at the HTTP edge:
7984
* if a plugin wanted to surface a unique-conflict as a typed
8085
* domain error (e.g. "source already exists") it should
@@ -97,6 +102,12 @@ export const capture = <A, E, R>(
97102
Effect.flatMap((traceId) => Effect.fail(new InternalError({ traceId }))),
98103
),
99104
),
105+
Effect.catchTag("StorageConnectionError", (err) =>
106+
resolveCapture.pipe(
107+
Effect.flatMap((c) => c.captureException(Cause.fail(err))),
108+
Effect.flatMap((traceId) => Effect.fail(new InternalError({ traceId }))),
109+
),
110+
),
100111
) as Effect.Effect<A, Exclude<E, StorageFailure> | InternalError, R>;
101112

102113
/**

packages/core/sdk/src/errors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export const isUserActionableError = (value: unknown): value is UserActionableEr
3232
/* The failure set the SDK surfaces. `execute`'s invoke failures are ported from
3333
* v1 but re-keyed by `address` (the full `tools.<integration>.<owner>.<connection>.<tool>`
3434
* handle) instead of an opaque tool id. Storage failures reuse fuma-runtime's
35-
* `StorageError`/`UniqueViolationError` (`StorageFailure`) — not redefined here. */
35+
* `StorageError`/`StorageConnectionError`/`UniqueViolationError`
36+
* (`StorageFailure`) — not redefined here. */
3637

3738
// ---------------------------------------------------------------------------
3839
// Tool lifecycle
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Regression for the "StorageError: Failed query: …" Sentry cluster
2+
// (NODE-CLOUDFLARE-WORKERS-5W/-5Q/-5P/-5R/-6D/-63/-61). One root cause fanned
3+
// out into six issues because `fumaFailureFromCause` copied the driver's error
4+
// text verbatim into `StorageError.message`: drizzle's `DrizzleQueryError`
5+
// message is `Failed query: <sql>\nparams: <bound values>`, so Sentry grouped
6+
// by SQL statement AND printed bound parameters (org ids, user ids, connection
7+
// names) in issue TITLES.
8+
//
9+
// Two contracts are pinned here:
10+
// 1. `StorageError.message` is built from a stable label plus the driver's
11+
// error CODE. Never the statement text, never the bound parameters.
12+
// 2. postgres.js connection faults are classified as a distinct
13+
// `StorageConnectionError` rather than melting into a generic
14+
// `StorageError`, so a pool-lifetime bug is not indistinguishable from a
15+
// malformed query.
16+
//
17+
// The fixtures below are synthetic reconstructions of the driver error shapes
18+
// (verified against node_modules/postgres/src/errors.js and
19+
// node_modules/drizzle-orm/errors.js); all identifiers are placeholders.
20+
21+
import { describe, expect, it } from "@effect/vitest";
22+
import { Cause, Effect, Exit, Predicate } from "effect";
23+
24+
import {
25+
fumaEffect,
26+
fumaFailureFromCause,
27+
isStorageFailure,
28+
StorageError,
29+
UniqueViolationError,
30+
} from "./fuma-runtime";
31+
32+
/** Shape of `postgres.js` `Errors.connection(code, options, socket)`. */
33+
const postgresConnectionError = (code: string): Error =>
34+
Object.assign(
35+
// oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs the native driver error this module has to classify
36+
new Error(`write ${code} db-placeholder.hyperdrive.local:5432`),
37+
{ code, errno: code, address: "db-placeholder.hyperdrive.local", port: 5432 },
38+
);
39+
40+
/** Shape of `postgres.js` `Errors.postgres(x)` — a server-side error report. */
41+
const postgresServerError = (code: string, message: string): Error =>
42+
// oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs the native driver error this module has to classify
43+
Object.assign(new Error(message), { code, severity: "ERROR" });
44+
45+
/**
46+
* Shape of drizzle's `DrizzleQueryError`: the statement text and the bound
47+
* parameters are baked into `message`, and the driver error hangs off `cause`.
48+
*/
49+
const drizzleQueryError = (sql: string, params: readonly unknown[], cause: unknown): Error => {
50+
// oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs the native driver error this module has to classify
51+
const error = new Error(`Failed query: ${sql}\nparams: ${params.join(",")}`);
52+
return Object.assign(error, { query: sql, params, cause });
53+
};
54+
55+
const SQL = 'select "key", "value" from "plugin_storage" where "scope_id" = $1 and "key" = $2';
56+
// Synthetic placeholders standing in for the real bound values (a WorkOS org id
57+
// and a connection-scoped storage key) that leaked into Sentry issue titles.
58+
const PARAMS = ["org_placeholder_0000", "oauth:integration-placeholder:refresh"] as const;
59+
60+
const expectNoDriverTextIn = (message: string): void => {
61+
expect(message).not.toContain("Failed query");
62+
expect(message).not.toContain("params:");
63+
expect(message).not.toContain("select");
64+
expect(message).not.toContain('plugin_storage"');
65+
for (const param of PARAMS) expect(message).not.toContain(param);
66+
};
67+
68+
describe("fumaFailureFromCause — message shaping", () => {
69+
it("does not put the statement text or bound parameters in the message", () => {
70+
const failure = fumaFailureFromCause(
71+
"plugin_storage.findFirst",
72+
drizzleQueryError(SQL, PARAMS, postgresConnectionError("CONNECTION_ENDED")),
73+
);
74+
75+
expectNoDriverTextIn(failure.message ?? "");
76+
});
77+
78+
it("builds a stable message from the label and the driver error code", () => {
79+
const failure = fumaFailureFromCause(
80+
"plugin_storage.findFirst",
81+
drizzleQueryError(
82+
SQL,
83+
PARAMS,
84+
postgresServerError("42P01", 'relation "nope" does not exist'),
85+
),
86+
);
87+
88+
expect(Predicate.isTagged(failure, "StorageError")).toBe(true);
89+
expect(failure.message).toContain("plugin_storage.findFirst");
90+
expect(failure.message).toContain("42P01");
91+
expectNoDriverTextIn(failure.message ?? "");
92+
});
93+
94+
it("groups two different statements failing the same way onto one message", () => {
95+
const a = fumaFailureFromCause(
96+
"integration.findFirst",
97+
drizzleQueryError(
98+
'select * from "integration" where "slug" = $1',
99+
["integration-placeholder"],
100+
postgresServerError("57P01", "terminating connection due to administrator command"),
101+
),
102+
);
103+
const b = fumaFailureFromCause(
104+
"integration.findFirst",
105+
drizzleQueryError(
106+
'select * from "integration" where "owner" = $1 and "slug" = $2',
107+
["org_placeholder_0000", "other-integration-placeholder"],
108+
postgresServerError("57P01", "terminating connection due to administrator command"),
109+
),
110+
);
111+
112+
expect(a.message).toBe(b.message);
113+
});
114+
115+
it("still yields a stable message when the driver reports no code", () => {
116+
const failure = fumaFailureFromCause(
117+
"tool.findMany",
118+
drizzleQueryError(
119+
SQL,
120+
PARAMS,
121+
// oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs a codeless native driver error
122+
new Error("something went wrong in the driver"),
123+
),
124+
);
125+
126+
expect(Predicate.isTagged(failure, "StorageError")).toBe(true);
127+
expect(failure.message).toContain("tool.findMany");
128+
expectNoDriverTextIn(failure.message ?? "");
129+
expect(failure.message).not.toContain("something went wrong");
130+
});
131+
132+
it("keeps the full driver error reachable on `cause`", () => {
133+
const driver = drizzleQueryError(SQL, PARAMS, postgresConnectionError("CONNECTION_ENDED"));
134+
const failure = fumaFailureFromCause("plugin_storage.findFirst", driver);
135+
136+
expect((failure as { readonly cause?: unknown }).cause).toBe(driver);
137+
});
138+
});
139+
140+
describe("fumaFailureFromCause — classification", () => {
141+
it.each([
142+
["CONNECTION_ENDED"],
143+
["CONNECTION_CLOSED"],
144+
["CONNECTION_DESTROYED"],
145+
["CONNECT_TIMEOUT"],
146+
])("classifies postgres.js %s as a storage connection fault", (code) => {
147+
const failure = fumaFailureFromCause(
148+
"plugin_storage.findFirst",
149+
drizzleQueryError(SQL, PARAMS, postgresConnectionError(code)),
150+
);
151+
152+
expect(Predicate.isTagged(failure, "StorageConnectionError")).toBe(true);
153+
expect(failure).toEqual(
154+
expect.objectContaining({
155+
_tag: "StorageConnectionError",
156+
code,
157+
label: "plugin_storage.findFirst",
158+
}),
159+
);
160+
expect(typeof (failure as { readonly retryable?: unknown }).retryable).toBe("boolean");
161+
expectNoDriverTextIn(failure.message ?? "");
162+
});
163+
164+
it("classifies the workerd cross-request I/O rejection as a connection fault", () => {
165+
const failure = fumaFailureFromCause(
166+
"integration.findFirst",
167+
drizzleQueryError(
168+
'select * from "integration" where "slug" = $1',
169+
["integration-placeholder"],
170+
// oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs workerd's cross-request I/O rejection, which carries no code
171+
new Error(
172+
"Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler.",
173+
),
174+
),
175+
);
176+
177+
expect(Predicate.isTagged(failure, "StorageConnectionError")).toBe(true);
178+
expectNoDriverTextIn(failure.message ?? "");
179+
expect(failure.message).not.toContain("Cannot perform I/O");
180+
});
181+
182+
it("marks transient socket loss retryable and pool-lifetime faults not retryable", () => {
183+
const transient = fumaFailureFromCause(
184+
"tool.findMany",
185+
postgresConnectionError("CONNECTION_CLOSED"),
186+
) as { readonly retryable?: boolean };
187+
const lifetime = fumaFailureFromCause(
188+
"tool.findMany",
189+
postgresConnectionError("CONNECTION_ENDED"),
190+
) as { readonly retryable?: boolean };
191+
192+
expect(transient.retryable).toBe(true);
193+
expect(lifetime.retryable).toBe(false);
194+
});
195+
196+
it("still recognises unique violations by SQLSTATE", () => {
197+
const failure = fumaFailureFromCause(
198+
"connection.create",
199+
drizzleQueryError(
200+
'insert into "connection" ("owner","name") values ($1,$2)',
201+
["org_placeholder_0000", "connection-placeholder"],
202+
postgresServerError(
203+
"23505",
204+
'duplicate key value violates unique constraint "connection_pkey"',
205+
),
206+
),
207+
);
208+
209+
expect(failure).toBeInstanceOf(UniqueViolationError);
210+
expect(Predicate.isTagged(failure, "UniqueViolationError")).toBe(true);
211+
});
212+
213+
it("does not misread a unique violation as a connection fault", () => {
214+
const failure = fumaFailureFromCause(
215+
"connection.create",
216+
postgresServerError("23505", "duplicate key value violates unique constraint"),
217+
);
218+
219+
expect(Predicate.isTagged(failure, "StorageConnectionError")).toBe(false);
220+
});
221+
222+
it("passes an already-typed storage failure through untouched", () => {
223+
const original = new StorageError({
224+
message: 'FumaDB table "secret" is not available through this storage boundary.',
225+
cause: undefined,
226+
});
227+
228+
expect(fumaFailureFromCause("plugin_storage.findFirst", original)).toBe(original);
229+
});
230+
231+
it("treats a connection fault as a storage failure at the boundary", () => {
232+
const failure = fumaFailureFromCause(
233+
"plugin_storage.findFirst",
234+
postgresConnectionError("CONNECTION_ENDED"),
235+
);
236+
237+
expect(isStorageFailure(failure)).toBe(true);
238+
expect(fumaFailureFromCause("plugin_storage.findFirst", failure)).toBe(failure);
239+
});
240+
});
241+
242+
describe("fumaEffect", () => {
243+
it("maps a rejected driver promise onto the classified failure", async () => {
244+
const driverRejection = () =>
245+
// oxlint-disable-next-line executor/no-promise-reject -- boundary: fumaEffect's whole contract is adapting a rejected driver promise
246+
Promise.reject(drizzleQueryError(SQL, PARAMS, postgresConnectionError("CONNECTION_ENDED")));
247+
248+
const exit = await Effect.runPromiseExit(
249+
fumaEffect("plugin_storage.findFirst", driverRejection),
250+
);
251+
252+
expect(Exit.isFailure(exit)).toBe(true);
253+
const failure = Exit.isFailure(exit)
254+
? exit.cause.reasons.find(Cause.isFailReason)?.error
255+
: undefined;
256+
257+
expect(Predicate.isTagged(failure, "StorageConnectionError")).toBe(true);
258+
expectNoDriverTextIn(failure?.message ?? "");
259+
});
260+
});

0 commit comments

Comments
 (0)