opencode 类型化错误迁移:用 Schema.TaggedErrorClass 构建 Effect 服务的错误通道与 HTTP 边界
本篇基于 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。规范原文给出的目标共六条,逐条对应一次「错误该出现在哪一层」的裁定:
- 预期服务失败位于 Effect 的 error 通道——即
Effect<A, E>中的E,而不是 failure 通道(defect); - 服务接口在返回类型中暴露这些失败——错误是 API 契约的一部分,调用方必须显式处理;
- 领域错误一律用
Schema.TaggedErrorClass编写——错误类自带 schema 与_tag判别标签,可被catchTag/catchTags精确捕获; Effect.die(...)只保留给 defect:bug、不可能状态、被破坏的不变量,以及最终未知边界兜底;- HTTP 状态码与对外的 wire body 在 HTTP 路由边界处理,服务模块内部不感知传输层;
- 面向用户的边界渲染有结构的错误详情,而不是不透明的
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判别值,字段(sessionID、message)都是带类型约束的结构化数据;- 模块导出一个领域级
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.mapError、Effect.catchTag、Effect.catchTags把外部失败(文件系统、网络、Promise 拒绝)翻译成领域错误; - 禁止对「用户错误、IO、校验、资源缺失、鉴权、provider、worktree、busy 状态」这类预期失败使用
throw、Effect.die(...)或catchDefect。
最后一条是迁移判据的负面表述:只要失败在业务上是可以预期的,它就不允许出现在 failure 通道里。配套规划文档 error-boundaries-plan.md 补充了 Promise 边界的细化手法:当 Promise 拒绝值种类很多、但只有一两个是预期领域失败时,用 EffectPromise.refineRejection(...) 只映射已知拒绝形状,未知拒绝保留为 defect;当所有拒绝都该归同一错误时用 Effect.tryPromise({ try, catch });只有拒绝即 defect 的场景才用裸 Effect.promise(...)。
三、HTTP 边界形态:服务模块保持传输无关
规范明确划定边界:服务模块不导入 HTTP 状态码、HttpApiError、HttpServerResponse 或路由级错误 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.ts:notFound(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.json 以 defect 形态穿越了 HttpApi 边界,导致中间件用一个安全的泛化 UnknownError 替换了原本有用的 ConfigInvalidError。兼容性层面的修法是让配置解析/校验错误保持为客户端可见的 400;而目标架构更彻底:配置加载失败走 typed error channel,配置的 HTTP handler 把这些错误映射为已声明的 ConfigApiError 响应,通用中间件根本看不到它们。这个案例是全文「预期失败必须在 error channel 内」这一原则的反面教材。
七、迁移顺序:小垂直切片
规范建议按五步小切片推进,避免大爆炸式重构:
- 先修一个用户可见边界的渲染;
- 把一个服务域转换为
Schema.TaggedErrorClass错误; - 在受影响的 HTTP handler 中映射这些错误;
- 在可行时删除对应的基于 name 的中间件分支;
- 为服务错误标签与 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 响应。
九、延伸阅读
- 上游轨道总览:todo.md
- 迁移架构与逐域队列(含
EffectPromise.refineRejection的 Promise 细化手法、helper 模块规划):error-boundaries-plan.md - 公开 HTTP 错误词汇表与
notFound辅助:errors.ts - 端点级错误声明示例:groups/session.ts
- 服务层 typed error 落地示例:storage.ts
- 用户侧错误格式化实现:cli/error.ts
适用前提与限制:本规范针对 opencode 正在进行的 Effect/HttpApi 迁移(仓库处于迁移中途,部分域仍保留 NamedError 兼容路径),其中的 httpApiStatus、Schema.TaggedErrorClass、Cause.pretty 等 API 均以仓库当前依赖的 Effect 版本为准;对尚未完成迁移的模块(如 src/provider/provider.ts 的 ProviderInitError、src/mcp/index.ts 的 MCPFailed 等仍用 NamedError.create(...) 的领域),规范约定「改动到时再转」,而非全量强迁。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00