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

Showing the changes to apps/cloud/src (3 of 14 files). Show all

Unified · Split

3 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
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) =>

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t