首页
/ opencode 类型化错误迁移:用 Schema.TaggedErrorClass 构建 Effect 服务的错误通道与 HTTP 边界

opencode 类型化错误迁移:用 Schema.TaggedErrorClass 构建 Effect 服务的错误通道与 HTTP 边界

2026-09-06 12:07:56作者:仰钰奇

本篇基于 opencode 仓库中的类型化错误迁移规范 errors.md,完整讲解该项目如何把「预期失败」从 throw/defect 迁移到 Effect 的 typed error channel:服务层如何用 Schema.TaggedErrorClass 定义领域错误并暴露在方法签名中,HTTP 路由边界如何用 httpApiStatus 声明式错误映射状态码,以及 CLI/TUI 如何通过统一的 FormatError 渲染结构化错误。读完本文,你可以在 Effect + HttpApi 架构的服务中落地同样的错误分层与边界映射模式。

一、规范定位与迁移目标

该文档是 opencode Effect 迁移中 ERR(错误)、RENDER(渲染)、HTTP 三条工作轨道的现行参考规范,其上游总览见 todo.md,整体迁移架构与队列见 error-boundaries-plan.md。规范原文给出的目标共六条,逐条对应一次「错误该出现在哪一层」的裁定:

  1. 预期服务失败位于 Effect 的 error 通道——即 Effect<A, E> 中的 E,而不是 failure 通道(defect);
  2. 服务接口在返回类型中暴露这些失败——错误是 API 契约的一部分,调用方必须显式处理;
  3. 领域错误一律用 Schema.TaggedErrorClass 编写——错误类自带 schema 与 _tag 判别标签,可被 catchTag/catchTags 精确捕获;
  4. Effect.die(...) 只保留给 defect:bug、不可能状态、被破坏的不变量,以及最终未知边界兜底;
  5. HTTP 状态码与对外的 wire body 在 HTTP 路由边界处理,服务模块内部不感知传输层;
  6. 面向用户的边界渲染有结构的错误详情,而不是不透明的 Error: SomeName 字符串。

第 4、5、6 条构成了整套规范的核心分层思想:错误从服务内部向用户传递时,每跨一层边界(服务 → HTTP → 终端渲染)就由该层自己适配一次形状,而不是让同一个类身兼数职。

二、服务层错误形态:TaggedErrorClass + Error 联合 + Interface

规范给出的标准服务错误模板如下(以 session 服务为例):

export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
  sessionID: SessionID,
  message: Schema.String,
}) {}

export type Error = Storage.Error | SessionBusyError

export interface Interface {
  readonly get: (id: SessionID) => Effect.Effect<Info, Error>
}

三段各承担一个职责:

  • Schema.TaggedErrorClass 生成的错误类同时是 Effect 的 TaggedError 与一个带 schema 的可解码类型,"SessionBusyError"_tag 判别值,字段(sessionIDmessage)都是带类型约束的结构化数据;
  • 模块导出一个领域级 Error 联合类型,把本模块自身错误与依赖模块(如存储层)的错误聚合起来;
  • Interface 的每个方法都把该 Error 联合写进 error channel,让「预期会失败」成为签名上的显式承诺。

这一形态在源码中已有真实落地。存储模块 storage.ts 就按此模式编写:第 11 行定义 export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("NotFoundError", {...}),第 19 行导出领域联合 export type Error = FSUtil.Error | NotFoundError,第 53 行起是 export interface Interface。全仓库范围内(packages/opencode/src 下 28 个文件)已采用 TaggedErrorClass 的模块包括 session、provider、worktree、project、mcp、skill、installation、question、image、auth、LSP 客户端等,说明该形态已从规范进入主流实现。

规范同时列出了八条编写规则,这里完整保留,它们是 code review 时可直接对照的清单:

  • 预期领域失败用 Schema.TaggedErrorClass
  • 每个服务模块导出一个领域级 Error 联合;
  • 预期错误写进服务方法签名;
  • Effect.gen / Effect.fn 中需要立即失败时,用 yield* new DomainError(...) 直接抛出领域错误(走 error channel,而非 failure channel);
  • 需要为日志或调用方保留原始 cause 时,用 Schema.Defect 声明未知 cause 字段;
  • Effect.try(...)Effect.tryPromise(...)Effect.mapErrorEffect.catchTagEffect.catchTags 把外部失败(文件系统、网络、Promise 拒绝)翻译成领域错误;
  • 禁止对「用户错误、IO、校验、资源缺失、鉴权、provider、worktree、busy 状态」这类预期失败使用 throwEffect.die(...)catchDefect

最后一条是迁移判据的负面表述:只要失败在业务上是可以预期的,它就不允许出现在 failure 通道里。配套规划文档 error-boundaries-plan.md 补充了 Promise 边界的细化手法:当 Promise 拒绝值种类很多、但只有一两个是预期领域失败时,用 EffectPromise.refineRejection(...) 只映射已知拒绝形状,未知拒绝保留为 defect;当所有拒绝都该归同一错误时用 Effect.tryPromise({ try, catch });只有拒绝即 defect 的场景才用裸 Effect.promise(...)

三、HTTP 边界形态:服务模块保持传输无关

规范明确划定边界:服务模块不导入 HTTP 状态码、HttpApiErrorHttpServerResponse 或路由级错误 schema。HTTP handler 负责把服务错误翻译成公开的端点错误,模板如下:

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")))
})

要点是 Effect.catchTag_tag 精确匹配并调用边界辅助函数(如 notFound)构造公开错误。仓库中该辅助函数的真实定义见 errors.tsnotFound(message) 返回 ApiNotFoundError 实例。

端点定义负责声明哪些公开错误可能发出,公开 HTTP 错误 schema 通过 httpApiStatus(或等价的 HttpApi schema 注解)自带响应状态。规范引用了 Effect 官方 HttpApi 示例的两种写法,值得完整保留:

中间件级错误(附带鉴权安全定义):

export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
  "Unauthorized",
  { message: Schema.String },
  { httpApiStatus: 401 },
) {}

export class Authorization extends HttpApiMiddleware.Service<
  Authorization,
  {
    provides: CurrentUser
  }
>()("app/Authorization", {
  security: { bearer: HttpApiSecurity.bearer },
  error: Unauthorized,
}) {}

端点级错误(多个内部错误共用一个公开 wire 形状):

export class ConfigApiError extends Schema.ErrorClass<ConfigApiError>("ConfigApiError")(
  {
    name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")),
    data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }),
  },
  { httpApiStatus: 400 },
) {}

HttpApiEndpoint.get("get", "/config", {
  success: Config.Info,
  error: ConfigApiError,
})

端点级声明在源码中已成规模:groups/session.ts 里每个端点都显式列出 error: [HttpApiError.BadRequest, ApiNotFoundError] 之类的公开错误数组,写会话的端点则额外声明 SessionBusyError、权限端点声明 PermissionNotFoundError。路由层自己的 AGENTS.md 也把这条规则固化下来:公开 JSON 错误应是逐端点声明的显式 Schema.ErrorClass 契约,内置 HttpApiError.* 仅在空/打标 body 是目标 wire 形状时使用,领域与存储服务必须与 HttpApi 类型隔离。

何时服务错误与 HTTP 错误可以同一个类

规范的判定标准是:仅当 wire 形状被刻意设计为公开时,二者才允许是同一个类。一旦服务错误内部包含低层 cause、重试提示或不应暴露给 API 客户端的数据,就必须另写 HTTP 错误 schema。由此推导出两条反模式禁令:

  • 不要把所有领域错误映射进一个万能 HTTP 错误类;
  • 应按路由组维护一个小的公开错误词汇表:跨组共享形状(如 ApiNotFoundError)、路由专属形状(如 ConfigApiError),内置空 body 的 HttpApiError.* 仅在其生成的 body 与 SDK 面就是契约时使用。

仓库的公开错误词汇表完整定义在 errors.ts 中,可对照规范理解「小词汇表」的实际规模与状态码分配:

公开错误类 httpApiStatus 承载字段
InvalidRequestError 400 message、kind?、field?
UnauthorizedError 401 message
ForbiddenError 403 message
ConflictError 409 message、resource?
ProviderNotFoundError / ModelNotFoundError / SessionNotFoundError / MessageNotFoundError / QuestionNotFoundError / PermissionNotFoundError / McpServerNotFoundError / PtyNotFoundError / ProjectNotFoundError 404 各资源 ID + message
SessionBusyError 409 sessionID、message
UnknownError 500 message、ref?
UpstreamError 502 message、service?、status?
ServiceUnavailableError 503 message、service?
TimeoutError 504 message、operation?
ApiNotFoundError 404 固定的 { name: "NotFoundError", data: { message } } 遗留 wire 形状

注意 ApiNotFoundError 与其余类的差别:它用 Schema.ErrorClass 显式建模了遗留的 { name, data } body(第 178-186 行),这正是规范「保留旧 wire 契约」规则的落地方式——不靠通用中间件里的 NamedError.toObject(),而是把旧形状显式写进公开 API 错误 schema。

四、映射指导(Mapping Guidance)

规范对「服务错误 → HTTP 错误」的映射位置与粒度给出七条操作性指导,完整继承如下:

  • 一次性翻译直接内联在 handler 里;
  • 同一路由组内重复出现相同翻译时,才提取小的共享辅助函数(notFound 即属此类);
  • 不要创建巨大的 unknown -> status 映射器;
  • 不要把通用 HTTP 中间件膨胀成领域错误注册表;
  • 在刻意做破坏性 API 变更之前,保留现有公开 { name, data } body;
  • 内置 HttpApiError.* 只在其生成的 body 与 SDK 面是刻意公开的契约时使用;
  • 公开 HTTP 错误 body 的内部 wire 形状与服务错误不同时,优先 Schema.ErrorClass;服务/领域错误与天然按 _tag 打标的中间件错误,优先 Schema.TaggedErrorClass
  • 若需保留遗留 { name, data } body,就在公开 API 错误 schema 里显式建模该形状,而不是依赖通用中间件中的 NamedError.toObject()

这套指导的共同指向是:映射知识放在离路由最近的地方,而不是集中在某个通用中间件里

五、面向用户的渲染:FormatError 与聚合展示

HTTP 序列化和用户渲染是两个独立的边界:服务端负责发送结构化的公开错误,CLI 与 TUI 的代码则通过同一个共享格式化器渲染这些结构。

针对 SDK 调用 { throwOnError: true } 的场景,规范规定:生成的客户端可能把解码后的 response body 包在一个 Error 里,原始 body 仍可通过 error.cause.body 取回;FormatError 就是解开并渲染该 body 的正确位置,TUI 的聚合辅助函数应先调用 FormatError,再回退到通用的 Error.message/字符串渲染。这一约定在 cli/error.ts 中已实现:FormatError(input) 会递归检查 input.cause.body 并提取其中的 _tag 做格式化。

当多个并行的启动请求因同一根因失败时,应把渲染结果相同的消息分组、只列出一次受影响的请求名。规范给出的目标渲染示例:

Configuration is invalid at /path/to/opencode.json
↳ Expected object, got "not-object" provider.bad.options
Affected startup requests: config.providers, provider.list, app.agents, config.get

这个示例同时展示了规则的另一半价值:结构化领域错误让 CLI 能打印出「哪个文件、哪个路径、期望什么、实际是什么」级别的诊断,而非一行笼统报错。

六、中间件指导:只留横切关注点

规范要求 HTTP 中间件只做横切的事:鉴权、上下文、schema 解码格式化、路由、最终未知 defect 兜底。当前兼容性中间件仍然认识一些遗留领域错误;随着各路由组声明预期错误、handler 就地映射,该中间件应当缩小,且不应新增任何 name 检查

未知 500 响应的处理被固定为两步:服务端用 Cause.pretty(cause) 记录完整详情,同时返回安全的公开 body。

规范还用了一次真实事故来锚定这条规则——#27056 的配置启动回归:用户手写的无效 opencode.jsondefect 形态穿越了 HttpApi 边界,导致中间件用一个安全的泛化 UnknownError 替换了原本有用的 ConfigInvalidError。兼容性层面的修法是让配置解析/校验错误保持为客户端可见的 400;而目标架构更彻底:配置加载失败走 typed error channel,配置的 HTTP handler 把这些错误映射为已声明的 ConfigApiError 响应,通用中间件根本看不到它们。这个案例是全文「预期失败必须在 error channel 内」这一原则的反面教材。

七、迁移顺序:小垂直切片

规范建议按五步小切片推进,避免大爆炸式重构:

  1. 先修一个用户可见边界的渲染;
  2. 把一个服务域转换为 Schema.TaggedErrorClass 错误;
  3. 在受影响的 HTTP handler 中映射这些错误;
  4. 在可行时删除对应的基于 name 的中间件分支;
  5. 为服务错误标签与 HTTP wire body 各补充或更新聚焦测试。

规范点名的首批合适领域是:存储 not-found、worktree 错误、provider 鉴权校验错误(因为它们当前直接驱动 HTTP 行为);配置解析/校验错误同样适合早期切入,因为它阻塞启动且必须在 CLI 与 TUI 两个流程中都清晰渲染。对照 error-boundaries-plan.md 的迁移队列,storage not-found、worktree、provider 鉴权、provider model-not-found 已划掉(不再经由 defect 兜底或中间件特判),Session.BusyError 与删除宽泛 NamedError 中间件分支等项仍在进行中,说明上述顺序正在被执行。

八、PR 自检清单

提交类型化错误迁移的 PR 时,规范给出六条自检项,可直接用于 review:

  • [ ] 预期失败是 typed error 而非 defect;
  • [ ] 服务方法签名暴露了预期的错误联合;
  • [ ] HTTP handler 在边界处翻译领域错误;
  • [ ] 公开 HTTP 错误 body 保持现有 wire 契约;
  • [ ] 通用中间件变得更小或保持不变;
  • [ ] 聚焦测试同时覆盖服务错误与公开 HTTP 响应。

九、延伸阅读

适用前提与限制:本规范针对 opencode 正在进行的 Effect/HttpApi 迁移(仓库处于迁移中途,部分域仍保留 NamedError 兼容路径),其中的 httpApiStatusSchema.TaggedErrorClassCause.pretty 等 API 均以仓库当前依赖的 Effect 版本为准;对尚未完成迁移的模块(如 src/provider/provider.tsProviderInitErrorsrc/mcp/index.tsMCPFailed 等仍用 NamedError.create(...) 的领域),规范约定「改动到时再转」,而非全量强迁。

登录后查看全文
热门项目推荐
相关项目推荐