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 @@
1import { Context, Effect, Layer } from "effect";1import { Context, Effect, Layer } from "effect";
2import { makeUserStore } from "../services/user-store";2import { makeUserStore } from "../services/user-store";
3import { DbService } from "../services/db";3import { DbService } from "../services/db";
4import { UserStoreError } from "./errors";4import { UserStoreError, withServiceLogging } from "./errors";
5 5
6// AuthContext is defined in ./middleware.ts to keep middleware-related types together.6// AuthContext is defined in ./middleware.ts to keep middleware-related types together.
7export { AuthContext } from "./middleware";7export { AuthContext } from "./middleware";
@@ -12,24 +12,14 @@ export { AuthContext } from "./middleware"; · 4 unmodified lines
12 12
13type RawStore = ReturnType<typeof makeUserStore>;13type RawStore = ReturnType<typeof makeUserStore>;
14 14
15const makeService = (store: RawStore) => {15const makeService = (store: RawStore) => ({
16 const use = <A>(fn: (s: RawStore) => Promise<A>) =>16 use: <A>(fn: (s: RawStore) => Promise<A>) =>
17 Effect.tryPromise({17 withServiceLogging(
18 try: () => fn(store),18 "user_store",
19 catch: (cause) => cause,19 () => new UserStoreError(),
20 }).pipe(20 Effect.tryPromise({ try: () => fn(store), catch: (e) => e }),
21 Effect.tapError((cause) =>21 ),
22 Effect.sync(() => {22});
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};
33 23
34type UserStoreServiceType = ReturnType<typeof makeService>;24type UserStoreServiceType = ReturnType<typeof makeService>;
35 25
apps/cloud/src/auth/errors.ts+24−5
@@ -1,10 +1,6 @@
1import { HttpApiSchema } from "@effect/platform";1import { HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { 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.
8export class UserStoreError extends Schema.TaggedError<UserStoreError>()(4export class UserStoreError extends Schema.TaggedError<UserStoreError>()(
9 "UserStoreError",5 "UserStoreError",
10 {},6 {},
@@ -16,3 +12,26 @@ export class WorkOSError extends Schema.TaggedError<WorkOSError>()( · 5 unmodified lines
16 {},12 {},
17 HttpApiSchema.annotations({ status: 500 }),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 */
26export 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
4 4
5import { Context, Effect, Layer } from "effect";5import { Context, Effect, Layer } from "effect";
6import { WorkOS } from "@workos-inc/node/worker";6import { WorkOS } from "@workos-inc/node/worker";
7import { WorkOSError } from "./errors";7import { WorkOSError, withServiceLogging } from "./errors";
8import { server } from "../env";8import { server } from "../env";
9 9
10const COOKIE_NAME = "wos-session";10const COOKIE_NAME = "wos-session";
@@ -26,18 +26,10 @@ const make = Effect.gen(function* () { · 15 unmodified lines
26 const workos = new WorkOS({ apiKey, clientId });26 const workos = new WorkOS({ apiKey, clientId });
27 27
28 const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>28 const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>
29 Effect.tryPromise({29 withServiceLogging(
30 try: () => fn(workos),30 "workos",
31 catch: (cause) => cause,31 () => new WorkOSError(),
32 }).pipe(32 Effect.tryPromise({ try: () => fn(workos), catch: (e) => e }),
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"),
41 );33 );
42 34
43 const authenticateSealedSession = (sessionData: string) =>35 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