|
|
@@ -1,41 +1,22 @@
|
|
|
-# Typed error migration
|
|
|
+# Typed Error Migration
|
|
|
|
|
|
-Plan for moving `packages/opencode` from temporary defect/`NamedError`
|
|
|
-compatibility toward typed Effect service errors and explicit HTTP error
|
|
|
-contracts.
|
|
|
+This note expands the `ERR`, `RENDER`, and `HTTP` tracks from
|
|
|
+[`todo.md`](./todo.md). It is the current reference for expected failures,
|
|
|
+typed service errors, and HTTP error boundaries.
|
|
|
|
|
|
## Goal
|
|
|
|
|
|
- Expected service failures live on the Effect error channel.
|
|
|
- Service interfaces expose those failures in their return types.
|
|
|
-- Domain errors are authored with Effect Schema so they are reusable by services,
|
|
|
- tests, HTTP routes, tools, and OpenAPI generation.
|
|
|
-- HTTP status codes and wire compatibility are handled at the HTTP boundary, not
|
|
|
- inside service modules.
|
|
|
-- `Effect.die`, `throw`, `catchDefect`, and global cause inspection are reserved
|
|
|
- for defects, compatibility bridges, or final fallback behavior.
|
|
|
+- Domain errors are authored with `Schema.TaggedErrorClass`.
|
|
|
+- `Effect.die(...)` is reserved for defects: bugs, impossible states,
|
|
|
+ violated invariants, and final unknown-boundary fallbacks.
|
|
|
+- HTTP status codes and public wire bodies are handled at HTTP route
|
|
|
+ boundaries, not inside service modules.
|
|
|
+- User-facing boundaries render useful structured error details instead of
|
|
|
+ opaque `Error: SomeName` strings.
|
|
|
|
|
|
-## Current State
|
|
|
-
|
|
|
-- Many migrated services use Effect internally, but expected failures are still a
|
|
|
- mix of `NamedError.create(...)`, `namedSchemaError(...)`, `class extends Error`,
|
|
|
- `throw`, and `Effect.die(...)`.
|
|
|
-- Some services already use `Schema.TaggedErrorClass`, for example `Account`,
|
|
|
- `Auth`, `Permission`, `Question`, `Installation`, and parts of
|
|
|
- `Workspace`.
|
|
|
-- The temporary HttpApi compatibility middleware recognizes `NamedError`,
|
|
|
- `Session.BusyError`, and a few name-based cases, then emits the legacy
|
|
|
- `{ name, data }` JSON body.
|
|
|
-- Effect `HttpApi` only knows how to encode errors that are declared on the
|
|
|
- endpoint, group, or middleware. Undeclared expected errors become defects and
|
|
|
- eventually fall through to generic HTTP handling.
|
|
|
-- The temporary HttpApi error middleware catches defect-wrapped legacy errors to
|
|
|
- preserve runtime behavior, but it is intentionally a bridge rather than the
|
|
|
- final model.
|
|
|
-
|
|
|
-## End State
|
|
|
-
|
|
|
-Service modules own domain failures.
|
|
|
+## Service Error Shape
|
|
|
|
|
|
```ts
|
|
|
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
|
|
|
@@ -50,281 +31,90 @@ export interface Interface {
|
|
|
}
|
|
|
```
|
|
|
|
|
|
-HTTP modules own transport mapping.
|
|
|
-
|
|
|
-```ts
|
|
|
-const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
|
|
|
- return yield* session
|
|
|
- .get(ctx.params.sessionID)
|
|
|
- .pipe(
|
|
|
- Effect.catchTag("StorageNotFoundError", () => new SessionNotFoundHttpError({ sessionID: ctx.params.sessionID })),
|
|
|
- )
|
|
|
-})
|
|
|
-```
|
|
|
-
|
|
|
-HTTP-visible error schemas carry their own response status through Effect
|
|
|
-HttpApi's `httpApiStatus` annotation. Prefer `HttpApiSchema.status(...)`, or the
|
|
|
-equivalent declaration annotation, instead of maintaining a parallel status map.
|
|
|
-
|
|
|
-```ts
|
|
|
-export class SessionNotFoundHttpError extends Schema.TaggedErrorClass<SessionNotFoundHttpError>()(
|
|
|
- "SessionNotFoundHttpError",
|
|
|
- {
|
|
|
- sessionID: SessionID,
|
|
|
- message: Schema.String,
|
|
|
- },
|
|
|
- { httpApiStatus: 404 },
|
|
|
-) {}
|
|
|
-```
|
|
|
-
|
|
|
-Endpoint definitions still declare which HTTP-visible error schemas can be
|
|
|
-emitted. The status annotation is only used if the error is part of the endpoint,
|
|
|
-group, or middleware error schema and the handler fails with that error on the
|
|
|
-typed error channel.
|
|
|
+Rules:
|
|
|
|
|
|
-```ts
|
|
|
-HttpApiEndpoint.get("get", SessionPaths.get, {
|
|
|
- success: Session.Info,
|
|
|
- error: [SessionNotFoundHttpError, SessionBusyHttpError],
|
|
|
-})
|
|
|
-```
|
|
|
-
|
|
|
-The service error and HTTP error may be the same class when the wire shape is a
|
|
|
-deliberate public contract. They should be different classes when the service
|
|
|
-error contains internals, low-level causes, retry hints, or anything that should
|
|
|
-not be exposed to API clients.
|
|
|
-
|
|
|
-## Rules
|
|
|
-
|
|
|
-- Use `Schema.TaggedErrorClass` for new expected domain errors.
|
|
|
-- Include `cause: Schema.optional(Schema.Defect)` only when preserving an
|
|
|
- underlying unknown failure is useful for logs or callers.
|
|
|
-- Export a domain-level error union from each service module, for example
|
|
|
- `export type Error = NotFoundError | BusyError | Storage.Error`.
|
|
|
-- Put expected errors in service method signatures, for example
|
|
|
- `Effect.Effect<Result, Service.Error, R>`.
|
|
|
-- Use `yield* new DomainError(...)` for direct early failures inside
|
|
|
+- Use `Schema.TaggedErrorClass` for expected domain failures.
|
|
|
+- Export a domain-level `Error` union from each service module.
|
|
|
+- Put expected errors in service method signatures.
|
|
|
+- Use `yield* new DomainError(...)` for direct early failures in
|
|
|
`Effect.gen` / `Effect.fn`.
|
|
|
-- Use `Effect.try({ try, catch })`, `Effect.mapError`, or `Effect.catchTag` to
|
|
|
- convert external exceptions into domain errors.
|
|
|
-- Use `HttpApiSchema.status(...)` or `{ httpApiStatus: code }` on HTTP-visible
|
|
|
- error schemas so Effect `HttpApiBuilder` and OpenAPI generation get the status
|
|
|
- from the schema itself.
|
|
|
-- Do not use `Effect.die(...)` for user, IO, validation, missing-resource, auth,
|
|
|
- provider, worktree, or busy-state failures.
|
|
|
-- Do not use `catchDefect` to recover expected domain errors. If recovery is
|
|
|
- needed, the upstream effect should fail with a typed error instead.
|
|
|
-- Do not make service modules import `HttpApiError`, `HttpServerResponse`, HTTP
|
|
|
- status codes, or route-specific error schemas.
|
|
|
-- Keep raw `HttpRouter` routes free to use `HttpServerRespondable` when that is
|
|
|
- the right transport abstraction, but prefer declared `HttpApi` errors for
|
|
|
- normal JSON API endpoints.
|
|
|
+- Use `Schema.Defect` for unknown cause fields when preserving the cause is
|
|
|
+ useful for logs or callers.
|
|
|
+- Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`,
|
|
|
+ `Effect.catchTag`, and `Effect.catchTags` to translate external
|
|
|
+ failures into domain errors.
|
|
|
+- Do not use `throw`, `Effect.die(...)`, or `catchDefect` for expected
|
|
|
+ user, IO, validation, missing-resource, auth, provider, worktree, or
|
|
|
+ busy-state failures.
|
|
|
|
|
|
## HTTP Boundary Shape
|
|
|
|
|
|
-Create an HttpApi-local error module, likely
|
|
|
-`src/server/routes/instance/httpapi/errors.ts`.
|
|
|
-
|
|
|
-That module should provide:
|
|
|
+Service modules stay transport-agnostic. They should not import HTTP
|
|
|
+status codes, `HttpApiError`, `HttpServerResponse`, or route-specific
|
|
|
+error schemas.
|
|
|
|
|
|
-- Legacy-compatible public schemas for `{ name, data }` error bodies that must
|
|
|
- remain SDK-compatible while route groups declare typed errors.
|
|
|
-- Small constructors or mapping helpers for common API errors such as not found,
|
|
|
- bad request, conflict, and unknown internal errors.
|
|
|
-- Route-group-specific adapters only when they encode domain-specific public
|
|
|
- data.
|
|
|
-- A single place to document which public error shape is legacy-compatible and
|
|
|
- which shape is new Effect-native API surface.
|
|
|
-
|
|
|
-Avoid one giant `unknown -> status` mapper. Prefer small, explicit mappers close
|
|
|
-to the handler or route group.
|
|
|
+HTTP handlers translate service errors into public endpoint errors:
|
|
|
|
|
|
```ts
|
|
|
-const mapSessionError = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
|
- effect.pipe(
|
|
|
- Effect.catchTag("StorageNotFoundError", (error) => new SessionNotFoundHttpError({ message: error.message })),
|
|
|
- Effect.catchTag("SessionBusyError", (error) => new SessionBusyHttpError({ message: error.message })),
|
|
|
- )
|
|
|
+const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
|
|
|
+ return yield* session
|
|
|
+ .get(ctx.params.sessionID)
|
|
|
+ .pipe(Effect.catchTag("StorageNotFoundError", () => notFound("Session not found")))
|
|
|
+})
|
|
|
```
|
|
|
|
|
|
-Use built-in `HttpApiError.BadRequest`, `HttpApiError.NotFound`, and related
|
|
|
-types only when their generated response body and SDK surface are intentionally
|
|
|
-acceptable. Use a custom schema-backed error when clients need the legacy
|
|
|
-`{ name, data }` body or a domain-specific error payload.
|
|
|
-
|
|
|
-## Migration Phases
|
|
|
-
|
|
|
-### 1. Stabilize The Bridge
|
|
|
+Endpoint definitions declare which public errors can be emitted. Public
|
|
|
+HTTP error schemas carry their response status with `httpApiStatus` or the
|
|
|
+equivalent HttpApi schema annotation.
|
|
|
|
|
|
-Keep the temporary HttpApi error middleware only as a compatibility bridge while
|
|
|
-typed errors are introduced.
|
|
|
+The service error and HTTP error may be the same class only when the wire
|
|
|
+shape is intentionally public. Use separate HTTP error schemas when the
|
|
|
+service error contains internals, low-level causes, retry hints, or data
|
|
|
+that should not be exposed to API clients.
|
|
|
|
|
|
-- Add tests that prove the bridge catches legacy `NamedError` defects.
|
|
|
-- Add tests that prove declared HttpApi errors still use the declared endpoint
|
|
|
- contract.
|
|
|
-- Stop returning stack traces in unknown HTTP `500` responses; log the full
|
|
|
- `Cause.pretty(cause)` server-side instead.
|
|
|
-- Add a comment or TODO that names this plan and states the bridge must shrink
|
|
|
- as route groups migrate.
|
|
|
+## Mapping Guidance
|
|
|
|
|
|
-### 2. Define The Shared HTTP Error Helpers
|
|
|
+- Keep one-off translations inline in the handler.
|
|
|
+- Extract tiny shared helpers when the same translation repeats across a
|
|
|
+ route group.
|
|
|
+- Do not create one giant `unknown -> status` mapper.
|
|
|
+- Do not grow generic HTTP middleware into a registry of domain errors.
|
|
|
+- Preserve existing public `{ name, data }` bodies until a deliberate
|
|
|
+ breaking API change.
|
|
|
+- Use built-in `HttpApiError.*` only when its generated body and SDK
|
|
|
+ surface are intentionally the public contract.
|
|
|
|
|
|
-Add the `httpapi/errors.ts` module before converting route groups.
|
|
|
+## Middleware Guidance
|
|
|
|
|
|
-- Define a legacy `{ name, data }` body helper for SDK-compatible errors.
|
|
|
-- Define `UnknownError` for generic internal failures with a safe public message.
|
|
|
-- Define `BadRequestError` and `NotFoundError` equivalents only if the actual
|
|
|
- wire body must match the existing SDK surface.
|
|
|
-- Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or
|
|
|
- `{ httpApiStatus: code }`; do not keep a separate name-to-status table.
|
|
|
-- Keep conversion helpers pure and small. They should not inspect `Cause` or
|
|
|
- accept `unknown` unless they are final fallback helpers.
|
|
|
+HTTP middleware should be cross-cutting: auth, context, schema decode
|
|
|
+formatting, routing, and final unknown-defect fallback.
|
|
|
|
|
|
-### 3. Convert One Vertical Slice
|
|
|
+The current compatibility middleware still knows about some legacy domain
|
|
|
+errors. As route groups declare expected errors and handlers map them, that
|
|
|
+middleware should shrink. It should not gain new name checks.
|
|
|
|
|
|
-Start with session read routes because they already have local `mapNotFound`
|
|
|
-logic and are heavily covered by existing HttpApi tests.
|
|
|
+Unknown `500` responses should log full details server-side with
|
|
|
+`Cause.pretty(cause)` and return a safe public body.
|
|
|
|
|
|
-- Convert `Session.BusyError` from a plain `Error` to a typed service error, or
|
|
|
- add a typed wrapper while preserving the old constructor until callers are
|
|
|
- migrated.
|
|
|
-- Replace `catchDefect` in `httpapi/handlers/session.ts` with typed error
|
|
|
- mapping.
|
|
|
-- Add endpoint error schemas for the affected session endpoints.
|
|
|
-- Prove behavior with focused tests in `test/server/httpapi-session.test.ts`.
|
|
|
-- Remove the migrated cases from the global compatibility middleware.
|
|
|
+## Migration Order
|
|
|
|
|
|
-### 4. Convert Legacy NamedError Domains
|
|
|
-
|
|
|
-Move legacy `NamedError.create(...)` services to Effect Schema-backed errors in
|
|
|
-small domain PRs.
|
|
|
-
|
|
|
-Priority order:
|
|
|
-
|
|
|
-1. `storage/storage.ts` and `storage/db.ts` not-found errors.
|
|
|
-2. `worktree/index.ts` `Worktree*` errors.
|
|
|
-3. `provider/auth.ts` validation failures and `provider/provider.ts` model-not-found errors.
|
|
|
-4. `mcp/index.ts`, `skill/index.ts`, `lsp/client.ts`, and `ide/index.ts` service errors.
|
|
|
-5. Config and CLI-only errors after HTTP-facing domains are stable.
|
|
|
-
|
|
|
-For each domain:
|
|
|
-
|
|
|
-- Replace `NamedError.create(...)` with `Schema.TaggedErrorClass` when the error
|
|
|
- is primarily a service error.
|
|
|
-- Keep or add a separate HTTP error schema when the legacy `{ name, data }` wire
|
|
|
- shape must remain stable.
|
|
|
-- Update service interface return types to include the new error union.
|
|
|
-- Replace `throw new X(...)` inside `Effect.fn` with `yield* new X(...)`.
|
|
|
-- Replace async exceptions with `Effect.try({ catch })` or explicit `mapError`.
|
|
|
-- Add service-level tests that assert the error tag and data, not just the HTTP
|
|
|
- status.
|
|
|
-
|
|
|
-### 5. Declare HttpApi Errors Group By Group
|
|
|
-
|
|
|
-For each HttpApi group:
|
|
|
-
|
|
|
-- Inventory every service call and the typed errors it can return.
|
|
|
-- Add only the public error schemas that endpoint can actually emit.
|
|
|
-- Map service errors to HTTP errors in the handler file.
|
|
|
-- Keep built-in `HttpApiError` only for generic request/validation failures where
|
|
|
- the generated contract is accepted.
|
|
|
-- Update `httpapi/public.ts` compatibility transforms only when the generated
|
|
|
- spec cannot represent the desired source shape directly.
|
|
|
-- Regenerate the SDK after OpenAPI-visible changes and verify the diff is
|
|
|
- intentional.
|
|
|
-
|
|
|
-Suggested route order:
|
|
|
-
|
|
|
-1. `session` not-found and busy-state reads.
|
|
|
-2. `experimental` worktree mutations.
|
|
|
-3. `provider` auth and model selection errors.
|
|
|
-4. `mcp` OAuth and connection errors.
|
|
|
-5. Remaining route groups as typed error contracts are declared.
|
|
|
-
|
|
|
-### 6. Remove Defect Recovery
|
|
|
-
|
|
|
-After enough route groups declare their expected errors:
|
|
|
-
|
|
|
-- Delete `catchDefect` recovery for domain errors.
|
|
|
-- Delete name-prefix checks such as `error.name.startsWith("Worktree")` from
|
|
|
- HTTP middleware.
|
|
|
-- Delete `NamedError` branches from the Effect HttpApi compatibility middleware
|
|
|
- once no Effect route depends on them.
|
|
|
-- Leave one final unknown-defect fallback that logs server-side and returns a
|
|
|
- safe generic `500` body.
|
|
|
-
|
|
|
-## Inventory Checklist
|
|
|
-
|
|
|
-Use this checklist when touching a service or route group.
|
|
|
-
|
|
|
-- [ ] Does the service interface expose every expected failure in the Effect
|
|
|
- error type?
|
|
|
-- [ ] Are user-caused, provider-caused, IO, auth, missing-resource, and busy-state
|
|
|
- failures modeled as typed errors instead of defects?
|
|
|
-- [ ] Does the service avoid importing HTTP status, `HttpApiError`, or response
|
|
|
- classes?
|
|
|
-- [ ] Does the handler map each service error into a declared endpoint error?
|
|
|
-- [ ] Does the endpoint `error` field include every public error the handler can
|
|
|
- emit?
|
|
|
-- [ ] Does OpenAPI/SDK output either stay byte-identical or have an explicitly
|
|
|
- reviewed diff?
|
|
|
-- [ ] Do tests cover both service-level error typing and HTTP-level status/body?
|
|
|
-- [ ] Did the PR remove any now-unneeded case from the temporary compatibility
|
|
|
- middleware?
|
|
|
-
|
|
|
-## Testing Requirements
|
|
|
-
|
|
|
-For service conversions:
|
|
|
-
|
|
|
-- Test the service method directly with `testEffect(...)`.
|
|
|
-- Assert on `_tag` or class identity and the structured fields.
|
|
|
-- Avoid testing by string-matching `Cause.pretty(...)`.
|
|
|
-
|
|
|
-For HttpApi conversions:
|
|
|
-
|
|
|
-- Add or update the focused `test/server/httpapi-*.test.ts` file.
|
|
|
-- Assert status code, content type, and exact JSON body for declared public
|
|
|
- errors.
|
|
|
-- Add a regression test that the temporary middleware is no longer needed for the
|
|
|
- migrated route.
|
|
|
-- Keep compatibility tests aligned with the existing SDK contract until the
|
|
|
- public error shape intentionally changes.
|
|
|
-
|
|
|
-## Verification Commands
|
|
|
-
|
|
|
-Run from `packages/opencode` unless noted otherwise.
|
|
|
-
|
|
|
-```bash
|
|
|
-bun run prettier --write <changed files>
|
|
|
-bunx oxlint <changed files>
|
|
|
-bun typecheck
|
|
|
-bun run test -- test/server/httpapi-session.test.ts
|
|
|
-```
|
|
|
-
|
|
|
-Run SDK generation from the repo root when schemas or OpenAPI-visible errors
|
|
|
-change.
|
|
|
-
|
|
|
-```bash
|
|
|
-./packages/sdk/js/script/build.ts
|
|
|
-```
|
|
|
+Prefer small vertical slices:
|
|
|
|
|
|
-## Open Questions
|
|
|
+1. Fix rendering at one user-visible boundary.
|
|
|
+2. Convert one service domain to `Schema.TaggedErrorClass` errors.
|
|
|
+3. Map those errors at the affected HTTP handlers.
|
|
|
+4. Remove the corresponding name-based middleware branch if possible.
|
|
|
+5. Add or update focused tests for both service error tags and HTTP wire
|
|
|
+ bodies.
|
|
|
|
|
|
-- Should legacy V1 routes keep `{ name, data }` forever while V2 routes expose a
|
|
|
- more Effect-native tagged error body?
|
|
|
-- Should storage not-found remain generic, or should callers map it to
|
|
|
- domain-specific not-found errors before crossing service boundaries?
|
|
|
-- Should `namedSchemaError(...)` stay as a long-term public-wire helper, or only
|
|
|
- as a migration bridge for old `NamedError` contracts?
|
|
|
-- Which SDK version boundary lets us stop remapping built-in Effect HttpApi error
|
|
|
- schemas in `httpapi/public.ts`?
|
|
|
+Good early domains are storage not-found, worktree errors, and provider
|
|
|
+auth validation errors because they currently drive HTTP behavior.
|
|
|
|
|
|
-## Success Criteria
|
|
|
+## Checklist For A PR
|
|
|
|
|
|
-- New service code no longer uses `die` for expected failures.
|
|
|
-- A route reviewer can read an endpoint definition and see every public error it
|
|
|
- can return.
|
|
|
-- The temporary HttpApi error middleware shrinks over time instead of gaining new
|
|
|
- name-based cases.
|
|
|
-- Service tests prove domain error types without going through HTTP.
|
|
|
-- HTTP tests prove status/body contracts without relying on defect recovery.
|
|
|
+- [ ] Expected failures are typed errors, not defects.
|
|
|
+- [ ] Service method signatures expose the expected error union.
|
|
|
+- [ ] HTTP handlers translate domain errors at the boundary.
|
|
|
+- [ ] Public HTTP error bodies preserve existing wire contracts.
|
|
|
+- [ ] Generic middleware gets smaller or stays unchanged.
|
|
|
+- [ ] Focused tests cover the service error and any public HTTP response.
|