* 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).
Unified · Split
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | 2 | import { makeUserStore } from "../services/user-store"; |
| 3 | 3 | import { DbService } from "../services/db"; |
| 4 | −import { UserStoreError } from "./errors"; | |
| 4 | +import { UserStoreError, withServiceLogging } from "./errors"; | |
| 5 | 5 | |
| 6 | 6 | // AuthContext is defined in ./middleware.ts to keep middleware-related types together. |
| 7 | 7 | export { AuthContext } from "./middleware"; |
| @@ -12,24 +12,14 @@ export { AuthContext } from "./middleware"; · 4 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | type RawStore = ReturnType<typeof makeUserStore>; |
| 14 | 14 | |
| 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 | +}); | |
| 33 | 23 | |
| 34 | 24 | type UserStoreServiceType = ReturnType<typeof makeService>; |
| 35 | 25 | |
| @@ -1,10 +1,6 @@ | ||
| 1 | 1 | import { HttpApiSchema } from "@effect/platform"; |
| 2 | −import { Schema } from "effect"; | |
| 2 | +import { Effect, Schema } from "effect"; | |
| 3 | 3 | |
| 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. | |
| 8 | 4 | export class UserStoreError extends Schema.TaggedError<UserStoreError>()( |
| 9 | 5 | "UserStoreError", |
| 10 | 6 | {}, |
| @@ -16,3 +12,26 @@ export class WorkOSError extends Schema.TaggedError<WorkOSError>()( · 5 unmodified lines | ||
| 16 | 12 | {}, |
| 17 | 13 | HttpApiSchema.annotations({ status: 500 }), |
| 18 | 14 | ) {} |
| 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 | + ); | |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { Context, Effect, Layer } from "effect"; |
| 6 | 6 | import { WorkOS } from "@workos-inc/node/worker"; |
| 7 | −import { WorkOSError } from "./errors"; | |
| 7 | +import { WorkOSError, withServiceLogging } from "./errors"; | |
| 8 | 8 | import { server } from "../env"; |
| 9 | 9 | |
| 10 | 10 | const COOKIE_NAME = "wos-session"; |
| @@ -26,18 +26,10 @@ const make = Effect.gen(function* () { · 15 unmodified lines | ||
| 26 | 26 | const workos = new WorkOS({ apiKey, clientId }); |
| 27 | 27 | |
| 28 | 28 | 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 }), | |
| 41 | 33 | ); |
| 42 | 34 | |
| 43 | 35 | const authenticateSealedSession = (sessionData: string) => |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { Schema } from "effect"; | |
| 1 | +import { Data, Schema } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | import { ToolId, SecretId, PolicyId } from "./ids"; |
| 4 | 4 | |
| @@ -7,14 +7,13 @@ export class ToolNotFoundError extends Schema.TaggedError<ToolNotFoundError>()( · 2 unmodified lines | ||
| 7 | 7 | { toolId: ToolId }, |
| 8 | 8 | ) {} |
| 9 | 9 | |
| 10 | −export class ToolInvocationError extends Schema.TaggedError<ToolInvocationError>()( | |
| 10 | +export class ToolInvocationError extends Data.TaggedError( | |
| 11 | 11 | "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 | +}> {} | |
| 18 | 17 | |
| 19 | 18 | export class SecretNotFoundError extends Schema.TaggedError<SecretNotFoundError>()( |
| 20 | 19 | "SecretNotFoundError", |
| @@ -140,8 +140,8 @@ const DiscoveryDocumentModel = Schema.Struct({ · 139 unmodified lines | ||
| 140 | 140 | }); |
| 141 | 141 | type DiscoveryDocument = typeof DiscoveryDocumentModel.Type; |
| 142 | 142 | |
| 143 | −const toParseError = (message: string, error: unknown) => | |
| 144 | − new GoogleDiscoveryParseError({ message, error }); | |
| 143 | +const toParseError = (message: string, cause: unknown) => | |
| 144 | + new GoogleDiscoveryParseError({ message, cause }); | |
| 145 | 145 | |
| 146 | 146 | const decodeUnknownWith = <A>( |
| 147 | 147 | message: string, |
| @@ -436,7 +436,6 @@ const manifestMethodFromMethod = (input: { · 288 unmodified lines | ||
| 436 | 436 | if (!method.httpMethod) { |
| 437 | 437 | return yield* new GoogleDiscoveryParseError({ |
| 438 | 438 | message: `Google Discovery method '${methodId}' is missing httpMethod`, |
| 439 | − error: new Error(`Missing httpMethod for ${methodId}`), | |
| 440 | 439 | }); |
| 441 | 440 | } |
| 442 | 441 | |
| @@ -509,7 +508,6 @@ export const extractGoogleDiscoveryManifest = Effect.fn( · 66 unmodified lines | ||
| 509 | 508 | return yield* new GoogleDiscoveryParseError({ |
| 510 | 509 | message: |
| 511 | 510 | "Google Discovery document is missing one of: name, version, rootUrl", |
| 512 | − error: new Error("Invalid document"), | |
| 513 | 511 | }); |
| 514 | 512 | } |
| 515 | 513 | |
| @@ -1,21 +1,20 @@ | ||
| 1 | −import { Schema } from "effect"; | |
| 1 | +import { Data, Schema } from "effect"; | |
| 2 | +import type { Option } from "effect"; | |
| 2 | 3 | |
| 3 | −export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()( | |
| 4 | +export class GoogleDiscoveryParseError extends Data.TaggedError( | |
| 4 | 5 | "GoogleDiscoveryParseError", |
| 5 | − { | |
| 6 | − message: Schema.String, | |
| 7 | − error: Schema.Defect, | |
| 8 | − }, | |
| 9 | −) {} | |
| 6 | +)<{ | |
| 7 | + readonly message: string; | |
| 8 | + readonly cause?: unknown; | |
| 9 | +}> {} | |
| 10 | 10 | |
| 11 | −export class GoogleDiscoveryInvocationError extends Schema.TaggedError<GoogleDiscoveryInvocationError>()( | |
| 11 | +export class GoogleDiscoveryInvocationError extends Data.TaggedError( | |
| 12 | 12 | "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 | +}> {} | |
| 19 | 18 | |
| 20 | 19 | export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscoveryOAuthError>()( |
| 21 | 20 | "GoogleDiscoveryOAuthError", |
| @@ -277,7 +277,6 @@ const invoke = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 276 unmodified lines | ||
| 277 | 277 | return yield* new GoogleDiscoveryInvocationError({ |
| 278 | 278 | message: `Missing required ${parameter.location} parameter: ${parameter.name}`, |
| 279 | 279 | statusCode: Option.none(), |
| 280 | − error: undefined, | |
| 281 | 280 | }); |
| 282 | 281 | } |
| 283 | 282 | continue; |
| @@ -327,7 +326,7 @@ const invoke = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 43 unmodified lines | ||
| 327 | 326 | new GoogleDiscoveryInvocationError({ |
| 328 | 327 | message: `HTTP request failed: ${err.message}`, |
| 329 | 328 | statusCode: Option.none(), |
| 330 | − error: err, | |
| 329 | + cause: err, | |
| 331 | 330 | }), |
| 332 | 331 | ), |
| 333 | 332 | ); |
| @@ -1,10 +1,10 @@ | ||
| 1 | −import { Schema } from "effect"; | |
| 1 | +import { Data, Schema } from "effect"; | |
| 2 | +import type { Option } from "effect"; | |
| 2 | 3 | |
| 3 | 4 | export class GraphqlIntrospectionError extends Schema.TaggedError<GraphqlIntrospectionError>()( |
| 4 | 5 | "GraphqlIntrospectionError", |
| 5 | 6 | { |
| 6 | 7 | message: Schema.String, |
| 7 | − error: Schema.Defect, | |
| 8 | 8 | }, |
| 9 | 9 | ) {} |
| 10 | 10 | |
| @@ -15,11 +15,10 @@ export class GraphqlExtractionError extends Schema.TaggedError<GraphqlExtraction · 4 unmodified lines | ||
| 15 | 15 | }, |
| 16 | 16 | ) {} |
| 17 | 17 | |
| 18 | −export class GraphqlInvocationError extends Schema.TaggedError<GraphqlInvocationError>()( | |
| 18 | +export class GraphqlInvocationError extends Data.TaggedError( | |
| 19 | 19 | "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 | +}> {} | |
| @@ -149,29 +149,31 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 148 unmodified lines | ||
| 149 | 149 | } |
| 150 | 150 | |
| 151 | 151 | const response = yield* client.execute(request).pipe( |
| 152 | + Effect.tapErrorCause((cause) => | |
| 153 | + Effect.logError("graphql introspection request failed", cause), | |
| 154 | + ), | |
| 152 | 155 | Effect.mapError( |
| 153 | 156 | (err) => |
| 154 | 157 | new GraphqlIntrospectionError({ |
| 155 | 158 | message: `Failed to reach GraphQL endpoint: ${err.message}`, |
| 156 | − error: err, | |
| 157 | 159 | }), |
| 158 | 160 | ), |
| 159 | 161 | ); |
| 160 | 162 | |
| 161 | 163 | if (response.status !== 200) { |
| 162 | − const body = yield* response.text.pipe(Effect.catchAll(() => Effect.succeed(""))); | |
| 163 | 164 | return yield* new GraphqlIntrospectionError({ |
| 164 | − message: `Introspection failed with status ${response.status}: ${body}`, | |
| 165 | − error: undefined, | |
| 165 | + message: `Introspection failed with status ${response.status}`, | |
| 166 | 166 | }); |
| 167 | 167 | } |
| 168 | 168 | |
| 169 | 169 | const raw = yield* response.json.pipe( |
| 170 | + Effect.tapErrorCause((cause) => | |
| 171 | + Effect.logError("graphql introspection JSON parse failed", cause), | |
| 172 | + ), | |
| 170 | 173 | Effect.mapError( |
| 171 | − (err) => | |
| 174 | + () => | |
| 172 | 175 | new GraphqlIntrospectionError({ |
| 173 | 176 | message: `Failed to parse introspection response as JSON`, |
| 174 | − error: err, | |
| 175 | 177 | }), |
| 176 | 178 | ), |
| 177 | 179 | ); |
| @@ -180,16 +182,13 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 2 unmodified lines | ||
| 180 | 182 | |
| 181 | 183 | if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) { |
| 182 | 184 | 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)`, | |
| 186 | 186 | }); |
| 187 | 187 | } |
| 188 | 188 | |
| 189 | 189 | if (!json.data?.__schema) { |
| 190 | 190 | return yield* new GraphqlIntrospectionError({ |
| 191 | 191 | message: "Introspection response missing __schema", |
| 192 | − error: undefined, | |
| 193 | 192 | }); |
| 194 | 193 | } |
| 195 | 194 | |
| @@ -216,6 +215,5 @@ export const parseIntrospectionJson = ( · 20 unmodified lines | ||
| 216 | 215 | catch: (err) => |
| 217 | 216 | new GraphqlIntrospectionError({ |
| 218 | 217 | message: `Failed to parse introspection JSON: ${err instanceof Error ? err.message : String(err)}`, |
| 219 | − error: err, | |
| 220 | 218 | }), |
| 221 | 219 | }); |
| @@ -109,7 +109,7 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* ( · 108 unmodified lines | ||
| 109 | 109 | new GraphqlInvocationError({ |
| 110 | 110 | message: `GraphQL request failed: ${err.message}`, |
| 111 | 111 | statusCode: Option.none(), |
| 112 | − error: err, | |
| 112 | + cause: err, | |
| 113 | 113 | }), |
| 114 | 114 | ), |
| 115 | 115 | ); |
| @@ -1,9 +1,6 @@ | ||
| 1 | −import { Schema } from "effect"; | |
| 1 | +import { Data } from "effect"; | |
| 2 | 2 | |
| 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 | +}> {} | |
| @@ -1,10 +1,10 @@ | ||
| 1 | −import { Schema } from "effect"; | |
| 1 | +import { Data, Schema } from "effect"; | |
| 2 | +import type { Option } from "effect"; | |
| 2 | 3 | |
| 3 | 4 | export class OpenApiParseError extends Schema.TaggedError<OpenApiParseError>()( |
| 4 | 5 | "OpenApiParseError", |
| 5 | 6 | { |
| 6 | 7 | message: Schema.String, |
| 7 | − error: Schema.Defect, | |
| 8 | 8 | }, |
| 9 | 9 | ) {} |
| 10 | 10 | |
| @@ -15,11 +15,10 @@ export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtraction · 4 unmodified lines | ||
| 15 | 15 | }, |
| 16 | 16 | ) {} |
| 17 | 17 | |
| 18 | −export class OpenApiInvocationError extends Schema.TaggedError<OpenApiInvocationError>()( | |
| 18 | +export class OpenApiInvocationError extends Data.TaggedError( | |
| 19 | 19 | "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 | +}> {} | |
| @@ -76,7 +76,6 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 75 unmodified lines | ||
| 76 | 76 | return yield* new OpenApiInvocationError({ |
| 77 | 77 | message: `Missing required path parameter: ${param.name}`, |
| 78 | 78 | statusCode: Option.none(), |
| 79 | − error: undefined, | |
| 80 | 79 | }); |
| 81 | 80 | } |
| 82 | 81 | continue; |
| @@ -111,7 +110,6 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 28 unmodified lines | ||
| 111 | 110 | return yield* new OpenApiInvocationError({ |
| 112 | 111 | message: `Unresolved path parameters: ${[...new Set(unresolved)].join(", ")}`, |
| 113 | 112 | statusCode: Option.none(), |
| 114 | − error: undefined, | |
| 115 | 113 | }); |
| 116 | 114 | } |
| 117 | 115 | |
| @@ -245,7 +243,7 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 127 unmodified lines | ||
| 245 | 243 | new OpenApiInvocationError({ |
| 246 | 244 | message: `HTTP request failed: ${err.message}`, |
| 247 | 245 | statusCode: Option.none(), |
| 248 | − error: err, | |
| 246 | + cause: err, | |
| 249 | 247 | }), |
| 250 | 248 | ), |
| 251 | 249 | ); |
| @@ -20,7 +20,6 @@ export const parse = Effect.fn("OpenApi.parse")(function* (input: string) { · 19 unmodified lines | ||
| 20 | 20 | catch: (error) => |
| 21 | 21 | new OpenApiParseError({ |
| 22 | 22 | message: `Failed to parse OpenAPI document: ${error instanceof Error ? error.message : String(error)}`, |
| 23 | − error, | |
| 24 | 23 | }), |
| 25 | 24 | }); |
| 26 | 25 | |