首页
/ opencode 错误边界设计:移除 NamedError,让每个边界只负责自己的错误形状

opencode 错误边界设计:移除 NamedError,让每个边界只负责自己的错误形状

2026-09-06 20:56:06作者:裴锟轩Denise

本文基于 opencode 仓库中的错误边界迁移规划文档 error-boundaries-plan.md,讲清楚该项目的核心错误处理策略:如何把过去由 NamedError 一把抓的"连通组织"式错误,拆分为领域错误、HTTP 公开错误、CLI 渲染、会话消息可见错误四个互不越界的层次。读完你可以掌握在 Effect 体系下定义 Schema.TaggedErrorClass 领域错误、在 Promise 边界用 refineRejection 精化拒绝值、以及在各边界写适配器而不让 HTTP 中间件感知领域错误的具体做法,并能对照仓库中的迁移队列与 PR 检查表评估任意一个错误模块是否已正确分层。

背景:为什么要拆掉 NamedError

该规划的目标一句话概括:移除 NamedError 作为连通组织(connective tissue),同时保持公开 wire 契约稳定。规划文档指出的核心问题是:同一个服务错误不应该同时充当 HTTP 响应体、CLI 格式化器和会话事件体,每个边界(seam)应把错误适配成自己拥有的形状。

这带来了三个实际痛点,文档以 "Provider Model Not Found" 为例列出:

  • Effect.fnthrow 一个 NamedError,在没有兼容桥捕获时它会表现得像 defect(非预期故障),而不是类型化失败;
  • HTTP 中间件必须"知道"某个特定领域错误应该映射成 400,领域知识泄漏进了中间件;
  • 调用方通过 .data.* 读字段,被迫耦合在遗留的 { name, data } wire 形状上。

目标形状:四层错误,各管一段

规划文档给出了明确的四层目标形状:

Domain/service error
  Schema.TaggedErrorClass
  - catchable with catchTag / catchTags
  - appears in service method error type
  - no HTTP status
  - no toObject()

HTTP public error
  Schema.ErrorClass / TaggedErrorClass with httpApiStatus
  - endpoint-declared public contract
  - owns legacy { name, data } only when that is the SDK wire shape

CLI/user rendering
  FormatError and small format helpers
  - converts domain errors to text
  - preserves useful structured fields

Session/model-visible error
  first-class session/message error schema or helper
  - owns { name, data } event/message shape
  - not a service error class

关键规则是:服务错误不应同时是 HTTP body、CLI formatter 和 session event body,每个边界负责把错误适配成自己拥有的形状。

具体示例:ProviderModelNotFoundError 的前后对比

改造前:

export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
  providerID: ProviderID,
  modelID: ModelID,
  suggestions: Schema.optional(Schema.Array(Schema.String)),
})

改造后(以仓库当前实现为准,见 provider.ts):

export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
  providerID: ProviderID,
  modelID: ModelID,
  suggestions: Schema.optional(Schema.Array(Schema.String)),
  cause: Schema.optional(Schema.Defect),
}) {}

export interface Interface {
  readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
}

当前源码比规划示例更进一步:错误类带 cause 字段用于保留原始故障上下文,重写了 message getter(输出 Model not found: <provider>/<model> 并附带 Did you mean: ... 建议),并提供 static isInstance 辅助判断(见 provider.ts)。同文件中的 InitErrorNoProvidersErrorNoModelsError 也全部采用同样的 Schema.TaggedErrorClass 写法,并在 L1188-L1202 处以 DefaultModelError/Error 联合类型收口,InterfacegetModel 的返回类型正是 Effect.Effect<Model, ModelNotFoundError> —— 类型化错误直接出现在服务方法签名里,这正是规划中 "appears in service method error type" 的要求。

失败的产生方式也随之改变:getModel 在找不到 provider 或 model 时用 return yield* new ModelNotFoundError({ providerID, modelID, suggestions }) 走 fail 通道,而不是 throw,调用方可以拿到可用的 suggestions(候选名建议)做提示(见 provider.ts)。

边界适配器:同一个错误在四条路上的形状

规划文档为这个错误定义了四个边界适配器:

CLI
└─ FormatError sees _tag ProviderModelNotFoundError -> nice text

Session prompt
└─ catch ModelNotFoundError -> publish Session.Event.Error as message/session wire shape

HTTP route
└─ catch ModelNotFoundError -> declared BadRequest public API error when the endpoint needs it

HTTP middleware
└─ no Provider.ModelNotFoundError knowledge

仓库中可以逐一印证这些适配器:

  1. CLI: cli/error.ts 中的 FormatError 通过 configData 同时识别旧形状(input.name === taginput.data 为对象)和新形状(input._tag === tag),命中 ProviderModelNotFoundError 后输出多行友好提示,包括 Did you mean: ...Try: opencode models to list available models 和检查 opencode.json 的建议。这一"新旧双形状兼容"正是迁移期 CLI 渲染层的设计:旧调用方还能产生 { name, data } 时保持兼容,等所有调用方迁移完后即可删除兼容分支。
  2. Session prompt: session/prompt.tsProvider.ModelNotFoundError.isInstance(err) 捕获领域错误并转换为会话可见的错误形状,另一处 prompt.tsEffect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)) 把"模型不存在"降级为成功但为空。
  3. catchTag 用法: Effect.fn 产生的错误可通过 _tag 精确捕获,例如 provider.tsgetModel(...).pipe(Effect.catchTag("ProviderModelNotFoundError", () => Effect.succeed(undefined))),这就是规划中 "catchable with catchTag / catchTags" 的实际体现。
  4. HTTP route: 路由层捕获 ModelNotFoundError 后映射为 errors.ts 中声明的 ModelNotFoundError(带 httpApiStatus: 404)等公开 API 错误类,中间件则不再需要知道这个领域错误的存在 —— 这与迁移队列中 "Provider model not found no longer needs an HTTP middleware status special case"(已完成) 对应。

精化已知 Promise 失败:EffectPromise.refineRejection

当 Promise 边界可能以大量未知值 reject,但其中只有一两个属于"预期的领域失败"时,规划建议使用 EffectPromise.refineRejection(...):已知拒绝形状映射为类型化错误,未知拒绝保持为 defect。

const language =
  yield *
  EffectPromise.refineRejection(
    async () => loadFromProvider(),
    (cause) => (cause instanceof NoSuchModelError ? new ModelNotFoundError({ providerID, modelID, cause }) : undefined),
  )

该 helper 的当前实现位于 effect/promise.ts,逻辑很短:内部用 Effect.tryPromise(evaluate),在 catch 中若错误是 Cause.isUnknownError 则取其 cause;调用 refine(cause) 后,若返回了类型化错误则 Effect.fail(refined),否则 Effect.die(cause) 让未知拒绝保持为缺陷。生产调用点在 provider.tsgetLanguage:模型 SDK 加载失败时,只有 NoSuchModelError 被精化为 ModelNotFoundError(并把原始 cause 存入 cause 字段),其余任何异常都作为 defect 上报,不会被误判为"模型不存在"。

规划文档还给出了三个 Promise 边界工具的选用判据,值得直接照搬:

  • 每个拒绝值都应变成同一个预期错误类型 → 用 Effect.tryPromise({ try, catch });
  • 大部分拒绝仍是 defect,只精化少数已知拒绝类 → 用 EffectPromise.refineRejection(...);
  • 拒绝即缺陷、无需精化 → 只用 Effect.promise(...)

辅助模块:只在重复调用点证明边界真实存在时再加

规划强调 "Add helpers only when repeated call sites prove the seam is real",并给出了三类候选辅助模块。对照仓库现状:

HTTP API 错误(规划位置 src/server/routes/instance/httpapi/errors.ts)

职责:构造公开 HTTP 错误体、在需要时保留遗留 { name, data }、附加 httpApiStatus。规划建议的小 helper 是 notFound(message)badRequest(message)unknown(),并明确避免 mapAnyDomainError(error) 这类会把"巨型中间件映射器"问题重新引入的通用映射函数。

当前 errors.ts 与规划高度一致:定义了 20 余个带 httpApiStatus 的公开错误类(400 InvalidRequestError、401 UnauthorizedError、403 ForbiddenError、404 ProviderNotFoundError/ModelNotFoundError/SessionNotFoundError/ProjectNotFoundError 等、409 ConflictError/SessionBusyError、502 UpstreamError、503 ServiceUnavailableError、504 TimeoutError、500 UnknownError),并已在 errors.ts 提供了保留遗留 wire 形状的 ApiNotFoundError(Schema 为 { name: "NotFoundError", data: { message } })和对应 helper notFound(message)。这正是 "owns legacy { name, data } only when that is the SDK wire shape" 的落地方式。

会话/消息错误 wire helper

规划建议在 src/session/message-error.ts 附近或新建 src/session/event-error.ts,职责是构造 Session.Event.Error 与助手消息错误使用的 { name, data } 形状,替换各处 new NamedError.Unknown(...).toObject() 调用,使模型可见的错误体与服务/领域错误彻底分开。候选 helper:

unknown(message)
agentNotFound(agent, available)
commandNotFound(command, available)
modelNotFound(error: Provider.ModelNotFoundError)

当前 message-error.tsmessage-v2.ts 仍在 NamedError 迁移队列中(见下文),属于待完成项。

CLI 格式化器

规划位置为 src/cli/error.ts(重复出现才拆到领域局部格式化模块),职责是从类型化错误产出人类可读的终端消息,仅在兼容期内支持旧 { name, data } 形状。当前实现即 cli/error.tsFormatError,除上述 provider 错误外还覆盖 CliErrorMCPFailedAccountServiceError、各种 Config*Error 等,对 UICancelledError 返回空串静默处理。

迁移队列:已完成与待办

规划文档给出了一份分阶段的迁移队列,当前仓库状态与之吻合,可整体继承如下:

1. 从 HTTP 中间件移除领域知识

  • [x] Storage not found 不再经过 defect fallback 映射;
  • [x] Worktree 预期错误迁移为类型化错误;
  • [x] Provider auth 预期错误迁移为类型化错误;
  • [x] Provider model not found 不再需要 HTTP 中间件状态特判;
  • [ ] 转换 Session.BusyError 并在路由边界映射(注意路由层已有 SessionBusyError 公开错误类);
  • [ ] 当没有任何路由依赖 defect 包裹的遗留领域错误后,删除宽泛的 NamedError 中间件分支(当前该分支仍存在于 middleware/error.ts);
  • [ ] 保留唯一一个未知 defect 兜底:记录 Cause.pretty(cause) 并返回安全的 500 body。

2. 剩余的 NamedError.create(...) 服务错误

以下错误在"被触碰时"应改为 Schema.TaggedErrorClass:

  • [ ] src/provider/provider.tsProviderInitError(当前源码中 InitError 已是 TaggedErrorClass,见 provider.ts,此项可视为在后续改动中已推进);
  • [ ] src/storage/db.ts 的数据库 NotFoundError;
  • [ ] src/mcp/index.tsMCPFailed;
  • [ ] src/skill/index.tsSkillInvalidErrorSkillNameMismatchError;
  • [ ] src/lsp/client.tsLSPInitializeError;
  • [ ] src/ide/index.ts 的安装类错误;
  • [ ] src/config/error.tssrc/config/config.tssrc/config/markdown.ts 的配置错误 —— 这些在 CLI 中已有良好渲染,迁移时要小心保留诊断信息(CLI 侧 FormatError 已为 ConfigJsonErrorConfigDirectoryTypoErrorConfigInvalidError 等准备了新旧双形状解析)。

3. 会话/消息 wire 错误

这些不是普通服务错误,主要构造模型/会话可见的 { name, data } 对象:

  • [ ] 增加一等公民的 session/message 错误 wire helper;
  • [ ] 替换 session/prompt.ts 中的 new NamedError.Unknown(...).toObject();
  • [ ] 替换 config/skill/plugin 会话事件发布处的同类调用(涉及 session/message-error.tssession/message-v2.tsmcp/index.tsskill/index.ts 等仍引用 NamedError 的模块);
  • [ ] wire helper 就位后,把 message-error.tsmessage-v2.tsNamedError.create(...) 迁移走;
  • [ ] 更新重试/消息测试,断言 wire schema/helper 输出,而不是 NamedError 实例。

4. CLI 渲染

  • [x] 带 tag 的配置错误已能渲染有用诊断;
  • [x] Provider model not found 已支持旧 { name, data } 与新 _tag 两种形状渲染(见 cli/error.tsconfigData);
  • [ ] 随着更多领域迁移到 Schema.TaggedErrorClass,补充类型化渲染用例;
  • [ ] 当没有任何调用方还能产生旧形状时,最终移除旧形状兼容分支。

PR 检查表:每个迁移错误的验收标准

规划文档为每个错误迁移定义了可执行的验收清单,这也是评估"某边界是否被错误认知污染"的实用工具:

  • [ ] 领域错误是 Schema.TaggedErrorClass;
  • [ ] 服务方法在其 error 通道中暴露该类型化错误;
  • [ ] 没有任何服务错误仅为兼容而带 toObject();
  • [ ] CLI、HTTP、session/message 适配器各自拥有自己的输出形状;
  • [ ] HTTP 中间件变小或保持不变(绝不变大);
  • [ ] 聚焦测试覆盖该领域错误以及 PR 触碰到的公开渲染/wire 形状。

小结

这份规划把错误处理的重心从"一个万能错误基类"移到"每个边界只定义自己拥有的形状":领域层用 Schema.TaggedErrorClass 让错误进入 error 通道并可被 catchTag 精确捕获;HTTP 层用带 httpApiStatus 的端点声明类对外,遗留 { name, data } 只出现在 SDK wire 真正需要的地方;CLI 层用 FormatError 做双形状兼容渲染;会话层用独立的 wire helper 生产模型可见错误体。配合 EffectPromise.refineRejection 对 Promise 拒绝值的"已知精化、未知保持 defect"策略,以及上面这份逐条可核对的迁移队列与 PR 检查表,任何参与该仓库错误处理的改动都可以据此判断自己是否让各边界变得更小、更独立。

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