Skip to content

Commit d2ce9d7

Browse files
authored
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).
1 parent 0ccccd9 commit d2ce9d7

14 files changed

Lines changed: 93 additions & 107 deletions

File tree

‎apps/cloud/src/auth/context.ts‎

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
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‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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>()(
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‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
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* () {
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‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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>()(
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‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ const DiscoveryDocumentModel = Schema.Struct({
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: {
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(
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‎

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,6 @@ const invoke = Effect.fn("GoogleDiscovery.invoke")(function* (input: {
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: {
327326
new GoogleDiscoveryInvocationError({
328327
message: `HTTP request failed: ${err.message}`,
329328
statusCode: Option.none(),
330-
error: err,
329+
cause: err,
331330
}),
332331
),
333332
);
Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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
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‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,29 +149,31 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* (
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* (
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 = (
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‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* (
109109
new GraphqlInvocationError({
110110
message: `GraphQL request failed: ${err.message}`,
111111
statusCode: Option.none(),
112-
error: err,
112+
cause: err,
113113
}),
114114
),
115115
);

0 commit comments

Comments
 (0)