|
| 1 | +# Error handling — model + plumbing |
| 2 | + |
| 3 | +## Principle |
| 4 | + |
| 5 | +Errors are typed values that propagate through the call graph carrying |
| 6 | +their full causal structure. **Exactly one layer at the edge** consumes |
| 7 | +them and maps them to a public response. Plugin code never imports |
| 8 | +Sentry, never calls `captureException`, never wraps handlers with |
| 9 | +`sanitize*` helpers. |
| 10 | + |
| 11 | +Two error categories: |
| 12 | + |
| 13 | +- **Surfaceable**: typed Schema errors with a user-actionable message |
| 14 | + (`McpOAuthError`, `OpenApiParseError`, …). Carry through to the |
| 15 | + response with their own status (4xx) and message body. Not captured |
| 16 | + to any external sink — they're normal business outcomes. |
| 17 | +- **Internal**: truly unexpected (storage failure, third-party returned |
| 18 | + garbage, sync throw inside a handler). Captured via `ErrorCapture` |
| 19 | + **at the HTTP edge only**, then propagated as the shared |
| 20 | + `InternalError({ traceId })` schema — opaque to clients, fully |
| 21 | + detailed in Sentry. One shape, one trace id, one place to look up |
| 22 | + the cause. |
| 23 | + |
| 24 | +## Layering |
| 25 | + |
| 26 | +``` |
| 27 | + storage-core ──▶ sdk ──▶ api (HTTP edge) ──▶ host |
| 28 | + StorageError StorageError InternalError ErrorCaptureLive |
| 29 | + (raw) (raw) (captured) (Sentry) |
| 30 | +``` |
| 31 | + |
| 32 | +The SDK stays storage-typed. The HTTP edge (`@executor/api`) is the |
| 33 | +**only** layer that translates `StorageError → InternalError` and |
| 34 | +captures the cause to telemetry. Non-HTTP consumers (CLI, Promise |
| 35 | +SDK, tests) see raw `StorageError` in the typed channel and can react |
| 36 | +however they want. |
| 37 | + |
| 38 | +## Plumbing |
| 39 | + |
| 40 | +### Storage layer (`@executor/storage-core`) |
| 41 | + |
| 42 | +Emits two `Data.TaggedError` classes and nothing else observability-related: |
| 43 | + |
| 44 | +- `StorageError({ message, cause })` — the catch-all for non-recoverable |
| 45 | + backend failures. The `cause` travels as runtime data so the HTTP |
| 46 | + edge can capture it, but it's never serialised to the wire. |
| 47 | +- `UniqueViolationError({ model? })` — typed 4xx-shaped failure plugins |
| 48 | + want to react to (e.g. "source already exists"). |
| 49 | + |
| 50 | +Both are `Data.TaggedError`, not `Schema.TaggedError` — you physically |
| 51 | +can't `addError(...)` them on an HttpApi group, which enforces "these |
| 52 | +are internal types, not wire shapes". |
| 53 | + |
| 54 | +`DBAdapter` / `CustomAdapter` / `TypedAdapter` declare |
| 55 | +`Effect<X, StorageFailure>` where `StorageFailure = StorageError | |
| 56 | +UniqueViolationError`. No `Error`, no telemetry service in R. |
| 57 | + |
| 58 | +Storage-core has zero observability awareness — it just emits typed |
| 59 | +values and lets consumers decide what to do with them. |
| 60 | + |
| 61 | +### SDK (`@executor/sdk`) |
| 62 | + |
| 63 | +The SDK is entirely observability-free. |
| 64 | + |
| 65 | +- `createExecutor` requires no observability service. `R = never`. |
| 66 | +- `PluginCtx.storage`, `ctx.core.*`, `ctx.secrets.*`, `ctx.transaction` |
| 67 | + all surface raw `StorageFailure` in the typed error channel. Plugins |
| 68 | + can `Effect.catchTag("UniqueViolationError", …)` and translate to |
| 69 | + their own user-facing errors. |
| 70 | +- Executor public methods (`executor.tools.list()`, |
| 71 | + `executor.sources.refresh()`, etc.) also surface raw `StorageFailure`. |
| 72 | + |
| 73 | +No `liftStorage`, no `wrapAdapterForPlugin`, no `ErrorCapture` tag |
| 74 | +inside the SDK. The value proposition: an SDK consumer can write a CLI, |
| 75 | +a script, a promise-based wrapper, whatever — and the typed channel |
| 76 | +shows them exactly what can go wrong. |
| 77 | + |
| 78 | +### HTTP edge (`@executor/api/observability`) |
| 79 | + |
| 80 | +Owns the translation, the opaque wire schema, and the capture service. |
| 81 | + |
| 82 | +- `InternalError({ traceId })` — the public opaque 500 schema, with an |
| 83 | + `HttpApiSchema.annotations({ status: 500 })` annotation so the |
| 84 | + framework encodes it correctly. |
| 85 | +- `ErrorCapture` — tagged Effect service for recording unexpected |
| 86 | + causes. Shape: |
| 87 | + |
| 88 | + ```ts |
| 89 | + interface ErrorCaptureShape { |
| 90 | + readonly captureException: (cause: Cause<unknown>) => Effect<string> |
| 91 | + } |
| 92 | + ``` |
| 93 | + |
| 94 | + Optional — resolved via `Effect.serviceOption`; missing service = |
| 95 | + empty trace ids. Nothing breaks if it's not wired. |
| 96 | + |
| 97 | +- `capture(eff)` — the single translator. Catches `StorageError` on |
| 98 | + the typed channel, captures the cause via `ErrorCapture`, fails with |
| 99 | + `InternalError({ traceId })`. Catches `UniqueViolationError` and |
| 100 | + dies (plugins that want to surface it as a typed domain error should |
| 101 | + `Effect.catchTag` inside their own method first). Every other typed |
| 102 | + failure passes through. |
| 103 | + |
| 104 | + Every handler wraps its generator body with `capture(...)`: |
| 105 | + |
| 106 | + ```ts |
| 107 | + .handle("probeEndpoint", ({ payload }) => |
| 108 | + capture(Effect.gen(function* () { |
| 109 | + const ext = yield* McpExtensionService; |
| 110 | + return yield* ext.probeEndpoint(payload.endpoint); |
| 111 | + })), |
| 112 | + ) |
| 113 | + ``` |
| 114 | + |
| 115 | + One line at the top of each handler. No service-level proxy, no |
| 116 | + `Captured<T>` type gymnastics — the translation is visible right |
| 117 | + where it happens, and TypeScript rejects handlers that forget |
| 118 | + (because `StorageError` isn't in the group's `.addError` list). |
| 119 | + |
| 120 | +- `observabilityMiddleware(Api)` — defect safety net. An |
| 121 | + `HttpApiBuilder.middleware` layer that wraps the HttpApp once and |
| 122 | + catches any cause that escaped the typed channel (defects, |
| 123 | + interrupts, framework bugs) via `ErrorCapture`, returning a typed |
| 124 | + `InternalError({ traceId })`. Should rarely fire when the rest of |
| 125 | + the pipeline is well-typed. |
| 126 | + |
| 127 | +### Plugin SDK |
| 128 | + |
| 129 | +Plugin authors write normal Effect code. Their extension method error |
| 130 | +unions look like: |
| 131 | + |
| 132 | +```ts |
| 133 | +Effect.Effect<X, MyPluginTypedError | StorageError, never> |
| 134 | +``` |
| 135 | + |
| 136 | +Where `MyPluginTypedError` is the union of their own |
| 137 | +`Schema.TaggedError` classes (with `HttpApiSchema.annotations({ status: 4xx })`). |
| 138 | +`StorageError` is the raw storage tag — it bubbles up, and the HTTP |
| 139 | +edge translates it. |
| 140 | + |
| 141 | +Plugins never provide `ErrorCapture`, never import Sentry, never see |
| 142 | +`InternalError` in their typed channel. |
| 143 | + |
| 144 | +### API groups |
| 145 | + |
| 146 | +Each group declares its typed errors once at the group level: |
| 147 | + |
| 148 | +```ts |
| 149 | +class McpGroup extends HttpApiGroup.make("mcp") |
| 150 | + .add(endpoint1) |
| 151 | + .add(endpoint2) |
| 152 | + // … |
| 153 | + .addError(InternalError) |
| 154 | + .addError(McpOAuthError) |
| 155 | + .addError(McpConnectionError) |
| 156 | + // … |
| 157 | +{} |
| 158 | +``` |
| 159 | + |
| 160 | +No per-endpoint `addError`. The framework encodes each tagged error by |
| 161 | +its annotated status. |
| 162 | + |
| 163 | +### Hosts |
| 164 | + |
| 165 | +- **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides |
| 166 | + `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API |
| 167 | + layer in `protected-layers.ts` so it's available to both |
| 168 | + `observabilityMiddleware` (defect catchall) AND the per-handler |
| 169 | + `capture(...)` translation. Service tags (`ExecutorService`, |
| 170 | + `McpExtensionService`, etc.) hold the raw SDK shapes; the cloud app |
| 171 | + just does `Layer.succeed(McpExtensionService, executor.mcp)` — |
| 172 | + handlers do the translation themselves. (Distinct from |
| 173 | + `apps/cloud/src/services/telemetry.ts`, which is the OTEL→Axiom span |
| 174 | + bridge — "telemetry" in the tracing sense.) |
| 175 | +- **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a |
| 176 | + console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`) |
| 177 | + that prints the squashed cause + pretty cause to stderr and returns a |
| 178 | + short correlation id. |
| 179 | +- **Tests / Promise SDK / examples** — non-HTTP consumers see raw |
| 180 | + `StorageError` / `StorageFailure` in the SDK's typed channel and |
| 181 | + can match on it directly. |
| 182 | + |
| 183 | +### Anti-patterns |
| 184 | + |
| 185 | +- `Effect.orDie` at handler boundaries — silently turns recoverable |
| 186 | + failures into 500s with no telemetry (defects bypass typed-channel |
| 187 | + encoding). |
| 188 | +- Per-plugin `*InternalError` types — clients can't tell which plugin |
| 189 | + emitted a 500 anyway. Use the shared `InternalError`. |
| 190 | +- `sanitize*` helpers in handler files that `catchAllCause` + map to a |
| 191 | + generic 500 — same swallowing problem in disguise. Prefer wrapping |
| 192 | + the handler's `Effect.gen` body with `capture(...)`. |
| 193 | +- SDK code importing `Sentry.captureException` or referencing |
| 194 | + `InternalError` / `ErrorCapture` — translation lives strictly in |
| 195 | + `@executor/api`. If the SDK imports it, the layering is wrong. |
0 commit comments