History

Tighten HTTP error schemas, keep rich causes internally (#137)

* Tighten HTTP error schemas, keep rich causes internally Establish a clear rule: Schema.TaggedError is HTTP wire-facing — every field must be a safe public field. Data.TaggedError is internal-only; it can carry rich cause: unknown for logging/debugging and can't be accidentally wired to an HttpApi endpoint because it isn't a Schema. - OpenApiParseError (HTTP-exposed): drop error: Schema.Defect field - GraphqlIntrospectionError (HTTP-exposed): drop error: Schema.Defect field; log underlying causes via Effect.logError at construction sites in introspect.ts - OpenApiInvocationError, GraphqlInvocationError, GoogleDiscoveryParseError, GoogleDiscoveryInvocationError, KeychainError, ToolInvocationError: convert Schema.TaggedError -> Data.TaggedError, rename error -> cause - Cloud auth (UserStoreError, WorkOSError): keep empty schema + 500 annotation, log full cause via Effect.tapErrorCause at service wrappers * Extract withServiceLogging helper + errors.test.ts contract Collapse the repetitive tapErrorCause + mapError + withSpan ceremony at each service wrapper into one helper in auth/errors.ts. Add a real HttpApi integration test that pins down the contract: - Wire response contains only declared tagged-error fields - Server-side logs capture the full Cause chain (drizzle query, params, nested pg Error.cause) The test uses a real HttpApi + HttpApiBuilder.toWebHandler + fetch round-trip with a capturing Logger to verify both sides without mocking. Validates Option C from the earlier discussion: service-level logging is the only pattern that preserves the full cause because mapError discards it before any edge middleware could see it. * Remove errors.test.ts * Simplify graphql introspection error handling Drop ad-hoc Effect.logError sprinkles on branch-level failures (bad status, returned errors, missing __schema) — the tagged error's message already carries the useful info and there's no Cause chain to tap. Keep tapErrorCause + mapError inline for the two cases where an upstream error exists (HTTP request failure, JSON parse failure).

Rhys Sullivan committed 7 months ago

Commit d2ce9d71 parent 0ccccd914 files · +93 −107Browse files

Unified · Split

14 files
  1. apps/cloud/src/auth/context.ts +9 −19
  2. apps/cloud/src/auth/errors.ts +24 −5
  3. apps/cloud/src/auth/workos.ts +5 −13
  4. packages/core/sdk/src/errors.ts +7 −8
  5. packages/plugins/google-discovery/src/sdk/document.ts +2 −4
  6. packages/plugins/google-discovery/src/sdk/errors.ts +13 −14
  7. packages/plugins/google-discovery/src/sdk/invoke.ts +1 −2
  8. packages/plugins/graphql/src/sdk/errors.ts +8 −9
  9. packages/plugins/graphql/src/sdk/introspect.ts +9 −11
  10. packages/plugins/graphql/src/sdk/invoke.ts +1 −1
  11. packages/plugins/keychain/src/errors.ts +5 −8
  12. packages/plugins/openapi/src/sdk/errors.ts +8 −9
  13. packages/plugins/openapi/src/sdk/invoke.ts +1 −3
  14. packages/plugins/openapi/src/sdk/parse.ts +0 −1
apps/cloud/src/auth/context.ts+9−19
@@ -1,7 +1,7 @@
11 import { Context, Effect, Layer } from "effect";
22 import { makeUserStore } from "../services/user-store";
33 import { DbService } from "../services/db";
4−import { UserStoreError } from "./errors";
4+import { UserStoreError, withServiceLogging } from "./errors";
55
66 // AuthContext is defined in ./middleware.ts to keep middleware-related types together.
77 export { AuthContext } from "./middleware";
@@ -12,24 +12,14 @@ export { AuthContext } from "./middleware"; · 4 unmodified lines
1212
1313 type RawStore = ReturnType<typeof makeUserStore>;
1414
15−const makeService = (store: RawStore) => {
16− const use = <A>(fn: (s: RawStore) => Promise<A>) =>
17− Effect.tryPromise({
18− try: () => fn(store),
19− catch: (cause) => cause,
20− }).pipe(
21− Effect.tapError((cause) =>
22− Effect.sync(() => {
23− // eslint-disable-next-line no-console
24− console.error("[user_store] query failed:", cause);
25− }),
26− ),
27− Effect.mapError(() => new UserStoreError()),
28− Effect.withSpan("user_store"),
29− );
30−
31− return { use };
32−};
15+const makeService = (store: RawStore) => ({
16+ use: <A>(fn: (s: RawStore) => Promise<A>) =>
17+ withServiceLogging(
18+ "user_store",
19+ () => new UserStoreError(),
20+ Effect.tryPromise({ try: () => fn(store), catch: (e) => e }),
21+ ),
22+});
3323
3424 type UserStoreServiceType = ReturnType<typeof makeService>;
3525
apps/cloud/src/auth/errors.ts+24−5
@@ -1,10 +1,6 @@
11 import { HttpApiSchema } from "@effect/platform";
2−import { Schema } from "effect";
2+import { Effect, Schema } from "effect";
33
4−// Tagged errors returned to HTTP clients. The response payload must NEVER
5−// include internal details (SQL, stack traces, WorkOS error bodies) — those
6−// can leak schema + secrets. Keep the schema empty and log the `cause`
7−// server-side via Effect's logger when the error is constructed.
84 export class UserStoreError extends Schema.TaggedError<UserStoreError>()(
95 "UserStoreError",
106 {},
@@ -16,3 +12,26 @@ export class WorkOSError extends Schema.TaggedError<WorkOSError>()( · 5 unmodified lines
1612 {},
1713 HttpApiSchema.annotations({ status: 500 }),
1814 ) {}
15+
16+/**
17+ * Service-boundary error wrapper. Logs the full Cause chain (drizzle
18+ * query/params, pg error codes, nested Error.cause, etc.) via Effect's
19+ * structured logger, then maps to a tagged error so the HTTP wire
20+ * response contains only safe fields.
21+ *
22+ * Use this whenever a Promise-based API gets lifted into an Effect and
23+ * its failure needs both debuggable server-side logging and a safe
24+ * public shape.
25+ */
26+export const withServiceLogging = <A, E, R>(
27+ name: string,
28+ publicError: () => E,
29+ effect: Effect.Effect<A, unknown, R>,
30+): Effect.Effect<A, E, R> =>
31+ effect.pipe(
32+ Effect.tapErrorCause((cause) =>
33+ Effect.logError(`${name} failed`, cause),
34+ ),
35+ Effect.mapError(publicError),
36+ Effect.withSpan(name),
37+ );
apps/cloud/src/auth/workos.ts+5−13
@@ -4,7 +4,7 @@ · 3 unmodified lines
44
55 import { Context, Effect, Layer } from "effect";
66 import { WorkOS } from "@workos-inc/node/worker";
7−import { WorkOSError } from "./errors";
7+import { WorkOSError, withServiceLogging } from "./errors";
88 import { server } from "../env";
99
1010 const COOKIE_NAME = "wos-session";
@@ -26,18 +26,10 @@ const make = Effect.gen(function* () { · 15 unmodified lines
2626 const workos = new WorkOS({ apiKey, clientId });
2727
2828 const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>
29− Effect.tryPromise({
30− try: () => fn(workos),
31− catch: (cause) => cause,
32− }).pipe(
33− Effect.tapError((cause) =>
34− Effect.sync(() => {
35− // eslint-disable-next-line no-console
36− console.error("[workos] call failed:", cause);
37− }),
38− ),
39− Effect.mapError(() => new WorkOSError()),
40− Effect.withSpan("workos"),
29+ withServiceLogging(
30+ "workos",
31+ () => new WorkOSError(),
32+ Effect.tryPromise({ try: () => fn(workos), catch: (e) => e }),
4133 );
4234
4335 const authenticateSealedSession = (sessionData: string) =>
packages/core/sdk/src/errors.ts+7−8
@@ -1,4 +1,4 @@
1−import { Schema } from "effect";
1+import { Data, Schema } from "effect";
22
33 import { ToolId, SecretId, PolicyId } from "./ids";
44
@@ -7,14 +7,13 @@ export class ToolNotFoundError extends Schema.TaggedError<ToolNotFoundError>()( · 2 unmodified lines
77 { toolId: ToolId },
88 ) {}
99
10−export class ToolInvocationError extends Schema.TaggedError<ToolInvocationError>()(
10+export class ToolInvocationError extends Data.TaggedError(
1111 "ToolInvocationError",
12− {
13− toolId: ToolId,
14− message: Schema.String,
15− cause: Schema.optional(Schema.Unknown),
16− },
17−) {}
12+)<{
13+ readonly toolId: ToolId;
14+ readonly message: string;
15+ readonly cause?: unknown;
16+}> {}
1817
1918 export class SecretNotFoundError extends Schema.TaggedError<SecretNotFoundError>()(
2019 "SecretNotFoundError",
packages/plugins/google-discovery/src/sdk/document.ts+2−4
@@ -140,8 +140,8 @@ const DiscoveryDocumentModel = Schema.Struct({ · 139 unmodified lines
140140 });
141141 type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;
142142
143−const toParseError = (message: string, error: unknown) =>
144− new GoogleDiscoveryParseError({ message, error });
143+const toParseError = (message: string, cause: unknown) =>
144+ new GoogleDiscoveryParseError({ message, cause });
145145
146146 const decodeUnknownWith = <A>(
147147 message: string,
@@ -436,7 +436,6 @@ const manifestMethodFromMethod = (input: { · 288 unmodified lines
436436 if (!method.httpMethod) {
437437 return yield* new GoogleDiscoveryParseError({
438438 message: `Google Discovery method '${methodId}' is missing httpMethod`,
439− error: new Error(`Missing httpMethod for ${methodId}`),
440439 });
441440 }
442441
@@ -509,7 +508,6 @@ export const extractGoogleDiscoveryManifest = Effect.fn( · 66 unmodified lines
509508 return yield* new GoogleDiscoveryParseError({
510509 message:
511510 "Google Discovery document is missing one of: name, version, rootUrl",
512− error: new Error("Invalid document"),
513511 });
514512 }
515513
packages/plugins/google-discovery/src/sdk/errors.ts+13−14
@@ -1,21 +1,20 @@
1−import { Schema } from "effect";
1+import { Data, Schema } from "effect";
2+import type { Option } from "effect";
23
3−export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()(
4+export class GoogleDiscoveryParseError extends Data.TaggedError(
45 "GoogleDiscoveryParseError",
5− {
6− message: Schema.String,
7− error: Schema.Defect,
8− },
9−) {}
6+)<{
7+ readonly message: string;
8+ readonly cause?: unknown;
9+}> {}
1010
11−export class GoogleDiscoveryInvocationError extends Schema.TaggedError<GoogleDiscoveryInvocationError>()(
11+export class GoogleDiscoveryInvocationError extends Data.TaggedError(
1212 "GoogleDiscoveryInvocationError",
13− {
14− message: Schema.String,
15− statusCode: Schema.optionalWith(Schema.Number, { as: "Option" }),
16− error: Schema.Defect,
17− },
18−) {}
13+)<{
14+ readonly message: string;
15+ readonly statusCode: Option.Option<number>;
16+ readonly cause?: unknown;
17+}> {}
1918
2019 export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscoveryOAuthError>()(
2120 "GoogleDiscoveryOAuthError",
packages/plugins/google-discovery/src/sdk/invoke.ts+1−2
@@ -277,7 +277,6 @@ const invoke = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 276 unmodified lines
277277 return yield* new GoogleDiscoveryInvocationError({
278278 message: `Missing required ${parameter.location} parameter: ${parameter.name}`,
279279 statusCode: Option.none(),
280− error: undefined,
281280 });
282281 }
283282 continue;
@@ -327,7 +326,7 @@ const invoke = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 43 unmodified lines
327326 new GoogleDiscoveryInvocationError({
328327 message: `HTTP request failed: ${err.message}`,
329328 statusCode: Option.none(),
330− error: err,
329+ cause: err,
331330 }),
332331 ),
333332 );
packages/plugins/graphql/src/sdk/errors.ts+8−9
@@ -1,10 +1,10 @@
1−import { Schema } from "effect";
1+import { Data, Schema } from "effect";
2+import type { Option } from "effect";
23
34 export class GraphqlIntrospectionError extends Schema.TaggedError<GraphqlIntrospectionError>()(
45 "GraphqlIntrospectionError",
56 {
67 message: Schema.String,
7− error: Schema.Defect,
88 },
99 ) {}
1010
@@ -15,11 +15,10 @@ export class GraphqlExtractionError extends Schema.TaggedError<GraphqlExtraction · 4 unmodified lines
1515 },
1616 ) {}
1717
18−export class GraphqlInvocationError extends Schema.TaggedError<GraphqlInvocationError>()(
18+export class GraphqlInvocationError extends Data.TaggedError(
1919 "GraphqlInvocationError",
20− {
21− message: Schema.String,
22− statusCode: Schema.optionalWith(Schema.Number, { as: "Option" }),
23− error: Schema.Defect,
24− },
25−) {}
20+)<{
21+ readonly message: string;
22+ readonly statusCode: Option.Option<number>;
23+ readonly cause?: unknown;
24+}> {}
packages/plugins/graphql/src/sdk/introspect.ts+9−11
@@ -149,29 +149,31 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 148 unmodified lines
149149 }
150150
151151 const response = yield* client.execute(request).pipe(
152+ Effect.tapErrorCause((cause) =>
153+ Effect.logError("graphql introspection request failed", cause),
154+ ),
152155 Effect.mapError(
153156 (err) =>
154157 new GraphqlIntrospectionError({
155158 message: `Failed to reach GraphQL endpoint: ${err.message}`,
156− error: err,
157159 }),
158160 ),
159161 );
160162
161163 if (response.status !== 200) {
162− const body = yield* response.text.pipe(Effect.catchAll(() => Effect.succeed("")));
163164 return yield* new GraphqlIntrospectionError({
164− message: `Introspection failed with status ${response.status}: ${body}`,
165− error: undefined,
165+ message: `Introspection failed with status ${response.status}`,
166166 });
167167 }
168168
169169 const raw = yield* response.json.pipe(
170+ Effect.tapErrorCause((cause) =>
171+ Effect.logError("graphql introspection JSON parse failed", cause),
172+ ),
170173 Effect.mapError(
171− (err) =>
174+ () =>
172175 new GraphqlIntrospectionError({
173176 message: `Failed to parse introspection response as JSON`,
174− error: err,
175177 }),
176178 ),
177179 );
@@ -180,16 +182,13 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 2 unmodified lines
180182
181183 if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) {
182184 return yield* new GraphqlIntrospectionError({
183− // @effect-diagnostics-next-line preferSchemaOverJson:off
184− message: `Introspection returned errors: ${JSON.stringify(json.errors)}`,
185− error: undefined,
185+ message: `Introspection returned ${json.errors.length} error(s)`,
186186 });
187187 }
188188
189189 if (!json.data?.__schema) {
190190 return yield* new GraphqlIntrospectionError({
191191 message: "Introspection response missing __schema",
192− error: undefined,
193192 });
194193 }
195194
@@ -216,6 +215,5 @@ export const parseIntrospectionJson = ( · 20 unmodified lines
216215 catch: (err) =>
217216 new GraphqlIntrospectionError({
218217 message: `Failed to parse introspection JSON: ${err instanceof Error ? err.message : String(err)}`,
219− error: err,
220218 }),
221219 });
packages/plugins/graphql/src/sdk/invoke.ts+1−1
@@ -109,7 +109,7 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* ( · 108 unmodified lines
109109 new GraphqlInvocationError({
110110 message: `GraphQL request failed: ${err.message}`,
111111 statusCode: Option.none(),
112− error: err,
112+ cause: err,
113113 }),
114114 ),
115115 );
packages/plugins/keychain/src/errors.ts+5−8
@@ -1,9 +1,6 @@
1−import { Schema } from "effect";
1+import { Data } from "effect";
22
3−export class KeychainError extends Schema.TaggedError<KeychainError>()(
4− "KeychainError",
5− {
6− message: Schema.String,
7− cause: Schema.optional(Schema.Unknown),
8− },
9−) {}
3+export class KeychainError extends Data.TaggedError("KeychainError")<{
4+ readonly message: string;
5+ readonly cause?: unknown;
6+}> {}
packages/plugins/openapi/src/sdk/errors.ts+8−9
@@ -1,10 +1,10 @@
1−import { Schema } from "effect";
1+import { Data, Schema } from "effect";
2+import type { Option } from "effect";
23
34 export class OpenApiParseError extends Schema.TaggedError<OpenApiParseError>()(
45 "OpenApiParseError",
56 {
67 message: Schema.String,
7− error: Schema.Defect,
88 },
99 ) {}
1010
@@ -15,11 +15,10 @@ export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtraction · 4 unmodified lines
1515 },
1616 ) {}
1717
18−export class OpenApiInvocationError extends Schema.TaggedError<OpenApiInvocationError>()(
18+export class OpenApiInvocationError extends Data.TaggedError(
1919 "OpenApiInvocationError",
20− {
21− message: Schema.String,
22− statusCode: Schema.optionalWith(Schema.Number, { as: "Option" }),
23− error: Schema.Defect,
24− },
25−) {}
20+)<{
21+ readonly message: string;
22+ readonly statusCode: Option.Option<number>;
23+ readonly cause?: unknown;
24+}> {}
packages/plugins/openapi/src/sdk/invoke.ts+1−3
@@ -76,7 +76,6 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 75 unmodified lines
7676 return yield* new OpenApiInvocationError({
7777 message: `Missing required path parameter: ${param.name}`,
7878 statusCode: Option.none(),
79− error: undefined,
8079 });
8180 }
8281 continue;
@@ -111,7 +110,6 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 28 unmodified lines
111110 return yield* new OpenApiInvocationError({
112111 message: `Unresolved path parameters: ${[...new Set(unresolved)].join(", ")}`,
113112 statusCode: Option.none(),
114− error: undefined,
115113 });
116114 }
117115
@@ -245,7 +243,7 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 127 unmodified lines
245243 new OpenApiInvocationError({
246244 message: `HTTP request failed: ${err.message}`,
247245 statusCode: Option.none(),
248− error: err,
246+ cause: err,
249247 }),
250248 ),
251249 );
packages/plugins/openapi/src/sdk/parse.ts+0−1
@@ -20,7 +20,6 @@ export const parse = Effect.fn("OpenApi.parse")(function* (input: string) { · 19 unmodified lines
2020 catch: (error) =>
2121 new OpenApiParseError({
2222 message: `Failed to parse OpenAPI document: ${error instanceof Error ? error.message : String(error)}`,
23− error,
2423 }),
2524 });
2625