Error handling — model + plumbing
Principle
Errors are typed values that propagate through the call graph carrying
their full causal structure. Exactly one layer at the edge consumes
them and maps them to a public response. Plugin code never imports
Sentry, never calls captureException, never wraps handlers with
sanitize* helpers.
Two error categories:
- Surfaceable: typed Schema errors with a user-actionable message
(
McpOAuthError,OpenApiParseError, …). Carry through to the response with their own status (4xx) and message body. Not captured to any external sink — they're normal business outcomes. - Internal: truly unexpected (storage failure, third-party returned
garbage, sync throw inside a handler). Captured via
ErrorCaptureat the HTTP edge only, then propagated as the sharedInternalError({ traceId })schema — opaque to clients, fully detailed in Sentry. One shape, one trace id, one place to look up the cause.
Layering
storage-core ──▶ sdk ──▶ api (HTTP edge) ──▶ host
StorageError StorageError InternalError ErrorCaptureLive
(raw) (raw) (captured) (Sentry)
The SDK stays storage-typed. The HTTP edge (@executor-js/api) is the
only layer that translates StorageError → InternalError and
captures the cause to telemetry. Non-HTTP consumers (CLI, Promise
SDK, tests) see raw StorageError in the typed channel and can react
however they want.
Plumbing
Storage layer (@executor-js/storage-core)
Emits two Data.TaggedError classes and nothing else observability-related:
StorageError({ message, cause })— the catch-all for non-recoverable backend failures. Thecausetravels as runtime data so the HTTP edge can capture it, but it's never serialised to the wire.UniqueViolationError({ model? })— typed 4xx-shaped failure plugins want to react to (e.g. "source already exists").
Both are Data.TaggedError, not Schema.TaggedError — you physically
can't addError(...) them on an HttpApi group, which enforces "these
are internal types, not wire shapes".
DBAdapter / CustomAdapter / TypedAdapter declare
Effect<X, StorageFailure> where StorageFailure = StorageError | UniqueViolationError. No Error, no telemetry service in R.
Storage-core has zero observability awareness — it just emits typed values and lets consumers decide what to do with them.
SDK (@executor-js/sdk)
The SDK is entirely observability-free.
createExecutorrequires no observability service.R = never.PluginCtx.storage,ctx.core.*,ctx.secrets.*,ctx.transactionall surface rawStorageFailurein the typed error channel. Plugins canEffect.catchTag("UniqueViolationError", …)and translate to their own user-facing errors.- Executor public methods (
executor.tools.list(),executor.sources.refresh(), etc.) also surface rawStorageFailure.
No liftStorage, no wrapAdapterForPlugin, no ErrorCapture tag
inside the SDK. The value proposition: an SDK consumer can write a CLI,
a script, a promise-based wrapper, whatever — and the typed channel
shows them exactly what can go wrong.
HTTP edge (@executor-js/api/observability)
Owns the translation, the opaque wire schema, and the capture service.
-
InternalError({ traceId })— the public opaque 500 schema, with anHttpApiSchema.annotations({ status: 500 })annotation so the framework encodes it correctly. -
ErrorCapture— tagged Effect service for recording unexpected causes. Shape:interface ErrorCaptureShape { readonly captureException: (cause: Cause<unknown>) => Effect<string>; }Optional — resolved via
Effect.serviceOption; missing service = empty trace ids. Nothing breaks if it's not wired. -
capture(eff)— the single translator. CatchesStorageErroron the typed channel, captures the cause viaErrorCapture, fails withInternalError({ traceId }). CatchesUniqueViolationErrorand dies (plugins that want to surface it as a typed domain error shouldEffect.catchTaginside their own method first). Every other typed failure passes through.Every handler wraps its generator body with
capture(...):.handle("probeEndpoint", ({ payload }) => capture(Effect.gen(function* () { const ext = yield* McpExtensionService; return yield* ext.probeEndpoint(payload.endpoint); })), )One line at the top of each handler. No service-level proxy, no
Captured<T>type gymnastics — the translation is visible right where it happens, and TypeScript rejects handlers that forget (becauseStorageErrorisn't in the group's.addErrorlist). -
observabilityMiddleware(Api)— defect safety net. AnHttpApiBuilder.middlewarelayer that wraps the HttpApp once and catches any cause that escaped the typed channel (defects, interrupts, framework bugs) viaErrorCapture, returning a typedInternalError({ traceId }). Should rarely fire when the rest of the pipeline is well-typed.
Plugin SDK
Plugin authors write normal Effect code. Their extension method error unions look like:
Effect.Effect<X, MyPluginTypedError | StorageError, never>;
Where MyPluginTypedError is the union of their own
Schema.TaggedError classes (with HttpApiSchema.annotations({ status: 4xx })).
StorageError is the raw storage tag — it bubbles up, and the HTTP
edge translates it.
Plugins never provide ErrorCapture, never import Sentry, never see
InternalError in their typed channel.
API groups
Each group declares its typed errors once at the group level:
class McpGroup extends HttpApiGroup.make("mcp")
.add(endpoint1)
.add(endpoint2)
// …
.addError(InternalError)
.addError(McpOAuthError)
.addError(McpConnectionError) {
// …
}
No per-endpoint addError. The framework encodes each tagged error by
its annotated status.
Hosts
- Cloud Worker (
apps/cloud/src/observability.ts) — providesErrorCaptureLive, a Sentry-backed implementation. Wired at the API layer inprotected-layers.tsso it's available to bothobservabilityMiddleware(defect catchall) AND the per-handlercapture(...)translation. Service tags (ExecutorService,McpExtensionService, etc.) hold the raw SDK shapes; the cloud app just doesLayer.succeed(McpExtensionService, executor.mcp)— handlers do the translation themselves. (Distinct fromapps/cloud/src/services/telemetry.ts, which is the OTEL→Axiom span bridge — "telemetry" in the tracing sense.) - CLI (
apps/local/src/server/main.ts) — same pattern. Provides a console-basedErrorCaptureLive(apps/local/src/server/observability.ts) that prints the squashed cause + pretty cause to stderr and returns a short correlation id. - Tests / Promise SDK / examples — non-HTTP consumers see raw
StorageError/StorageFailurein the SDK's typed channel and can match on it directly.
Anti-patterns
Effect.orDieat handler boundaries — silently turns recoverable failures into 500s with no telemetry (defects bypass typed-channel encoding).- Per-plugin
*InternalErrortypes — clients can't tell which plugin emitted a 500 anyway. Use the sharedInternalError. sanitize*helpers in handler files thatcatchAllCause+ map to a generic 500 — same swallowing problem in disguise. Prefer wrapping the handler'sEffect.genbody withcapture(...).- SDK code importing
Sentry.captureExceptionor referencingInternalError/ErrorCapture— translation lives strictly in@executor-js/api. If the SDK imports it, the layering is wrong.