orion-error 0.8.2

Structured error governance for layered Rust systems
Documentation
# 很多系统不好维护,不是业务复杂,而是错误处理从没被认真设计

> 一个工业级系统和原型的区别,不只在正确路径上,更在失败路径上。

这是这个系列的上篇。本文先梳理核心矛盾,再给出 Wukong 错误治理模型的解决思路。如果你只关心方法论本身,可以直接跳读到[我们的方案](#我们的方案wukong-错误治理模型)。

---

你有没有见过这样的代码库?

同一个"订单不存在",A 模块返回一个 enum,B 模块返回一个字符串,C 模块直接 panic。网关层拼 JSON 的时候,每个 handler 各自为政:有的返回 `{"error": "not found"}`,有的返回 `{"code": 404, "msg": "resource missing"}`。出了故障,你打开日志,满屏的错误信息散落各处,没有一条完整的链路告诉你"这个失败从哪里来、经过了哪些层、根因是什么"。想重构错误类型?不敢动,因为你不知道上游谁在依赖那个错误字符串的内容。

如果你点头了,那你的项目大概率正处于**错误处理的无治理状态**。

---

## 错误处理的三条路,同一个问题

C++ 用异常,Unix 用返回值 + `errno`,Windows 用结构化错误码。表面上是三种语法,实际上在回答同一个问题:**失败信息怎么从发生点传递到处理点?**

异常选择了隐式长跳转:正常路径和错误路径分离,业务逻辑不再被 `if (ret < 0)` 打断。这是它真正优雅的地方。但代价也很明确:语言只给了 `throw` 和 `return`,没有告诉你线划在哪。用户输入不合法,是抛异常还是返回 error?订单库存不足,算业务逻辑还是异常?同一个函数,不同的人写,对"异常"的定义可能完全不同。分离了路径,但没有告诉我们分离的边界。

Rust 做了一个不同的选择:**彻底抛弃异常机制**。错误回到返回值:`Result<T, E>`,通过 `?` 操作符向上传播。初看像是退回了 C 语言的老路,但深入之后会发现,这是一种重新思考错误本质的姿态。

用一个比喻来说:

> **异常机制把错误处理搞成了"特区"。** 有自己的控制流(throw/unwind)、自己的语法(try-catch)、自己的运行时基础设施(栈展开表、landing pad)。即使异常从没发生,这些成本也在那里。两套规则、两套心智模型、两套代价。开发者在正常逻辑和异常逻辑之间反复切换,"该抛还是该 return"就成了永远的灰色区域。

**Rust 的选择是取消特区。** 错误就是值:和 `i32`、`String`、`Option<T>` 一样,通过返回值传递,通过 `match` 分派。没有隐式跳转,没有"正常代码"和"错误代码"的地位差别。`Result<T, E>` 不过是一个泛型枚举,`?` 不过是一个语法糖。

这恰恰是一种**平等对待**:错误处理不是寄生在正常逻辑上的附属机制,它本身就是正常逻辑的一部分。`Result` 写在签名里,调用方必须面对两种可能。代价是错误路径更显眼了,收益是不需要同时掌握两套系统。平等,意味着简单。

每次设计开发一个系统,都要面对同一个问题:错误处理体系怎么设计?从最初的"不管错误",到在框架里设计异常体系、错误码、错误接口,再到引入日志规范,这些工作总是占据大量时间和精力。

和多位资深开发者、架构师聊到这个话题,有一个共识:

> 能把代码写得简洁易懂的,是优秀开发者。能把错误处理做得干净漂亮的,才是顶尖程序员。

错误处理排在功能实现、算法性能、代码可读之后。它不是最急迫的,不是 PM 会催的,不是 code review 会重点看的。但正是它,决定了一个系统能不能长期稳定运行,能不能在故障面前快速恢复,能不能在人员更迭后仍然可维护。它是系统进化能力的底座。

Rust 的这个设计引出一个更本质的问题:**错误到底是什么?是意外,还是结果?** 如果错误是意外,异常是合理的:跳出去,别让它污染正常逻辑。但如果错误也是结果的一种:一个函数可能返回数据,也可能返回失败,两者都是这个函数的合法输出,那么错误就应该像返回值一样被显式传递、显式处理。

两种理解,引向两种设计哲学。哪种对?业界对此并没有共识。C++ 保留异常但也支持 `std::expected`,Java 固守异常但社区也在尝试 `Result` 式库,Go 用返回值但结构化信息需要额外约束,Rust 用 `Result` 但诊断链和边界策略仍要工程层面补足。每条路都有各自的取舍,说明这个问题远未被"解决",大家还在探索。

只能说,在错误处理这件事上,各种语言都还在探索基础能力:异常、返回值、`Result`、错误码,各有取舍,也都没有彻底解决问题。可从系统构建者的角度看,我们真正关心的到底是什么?

我们关心的,不是语法层面怎么抛、怎么传,而是失败信息在系统中如何被分类、保留、转换、暴露和观测。下面把这套思路展开:先说核心矛盾,再说解决思路。

---

## 错误处理,为什么是原型和工业级的分水岭?

原型只需要证明一件事:正确路径能跑通。

但工业级系统面对的是完全不同的现实:输入在变化,依赖在退化,网络在抖动,配置在漂移,数据在积累脏状态,业务规则在迭代。**系统不会长期运行在理想条件下。**

所以错误不是"正常逻辑之外的意外文本",它是系统在非理想条件下继续运行、恢复状态、决定对外响应、支持诊断时必须传递的**信息**。

很多项目早期把错误处理当成"每个函数自己的事"。调用链短、边界少的时候,这不是问题。但当错误信息开始跨越团队、子系统、服务边界、协议边界时,失败路径就变得不可治理。

这不是某个函数"写得不好",而是**缺乏统一的信息架构**。错误治理,就是这套信息架构,也是系统对抗腐化与脆弱的骨架。

---

## 行业一直在探索错误处理

行业从来都知道错误处理重要。C 用返回码,Java 用异常,Go 用显式 error,Rust 用 `Result<T, E>`。每种设计都有取舍。

但语言机制解决的是"怎么表达失败",不是"失败信息在系统中如何被分类、保留、转换、暴露和观测"。

来看看几个优秀项目的做法:

- **gRPC** 把跨语言 RPC 失败收敛为标准状态码:**稳定分类让调用方可重试、降级、告警**。
- **PostgreSQL** 用稳定 SQLSTATE 错误码,不依赖错误文本:**机器契约和人类文案应该分离**。
- **Kubernetes** 把就绪状态、失败原因写入 `status` 字段:**错误可以是可查询、可自动化处理的系统状态**。
- **rustc** 用错误码 + 源码位置 + label + note + help 构成诊断链:**诊断信息本身是产品体验**。

它们的形态不同,方向一致:**把失败路径设计成稳定的信息系统。**

---

## 核心矛盾:收敛 vs. 诊断

错误治理要面对一对根本矛盾。

调用方需要**稳定的、有限的分类**,否则没法做重试、降级、告警、返回给用户的决策。排障方需要**完整的、保留细节的信息**,否则没法定位根因。

如果你向调用方暴露太多技术细节,上层就会依赖数据库、网络库、第三方 SDK 的具体失败形态,底层一重构,错误契约就崩了。如果你只保留业务分类,排障时就会丢失关键路径:原始失败是什么?经过了哪些层?每层追加了什么上下文?

所以问题不是"要不要包装错误",而是:

**如何同时让错误在治理层面收敛,在诊断层面保留有效信息?**

接下来的问题是:现有语言机制和常见实践,为什么还没有把这件事真正解决?

---

## 语言给了你砖块,但没给你建筑

很多人会说:Java 的异常链、Go 的 `errors.Is` / `errors.As`、Rust 的 enum + cause chain,不是已经解决了这个问题吗?

它们是砖块,但砖块不等于建筑。问题出在三点:

**第一,错误标识不稳定。** Java 里异常类型承担分发角色:`catch (OrderNotFoundException e)`。但异常类型受继承层次控制,重构时会变。错误码只是异常的附赠字段。

**第二,分类空间天然膨胀。** 异常机制鼓励"一种失败一个类":`SubmitDependencyUnavailableException`、`InvalidStateException`……类数量跟随业务失败模式无限制增长,没有机制强制收敛到有限分类。

**第三,治理和诊断共用同一通道,互相牵制。** cause chain 解决的是诊断保留,不解决"该重试还是降级?该映射到哪个 HTTP 状态码?该暴露给用户还是只记日志?"这些治理动作散落在每个 handler、每个 catch 块里。

关键不在于用什么语言机制,而在于你的错误架构有没有这四根承重墙:**稳定错误标识、有限分类空间、诊断保留、集中边界策略。**

---

## 我们的方案:Wukong 错误治理模型

通过多年的实践,我逐渐找到了解决这个问题的方案。它的核心思想很简单:**用稳定契约让错误现形,用可靠诊断看清根因,用适配输出让不同接收端拿到稳定、合适的信息。**我把这套方法叫作 **Wukong 错误治理模型**。

为什么叫 Wukong?借用的正是悟空在西天路上一路降妖除魔的意象。放到软件开发里,我们要降伏、封印的是各种错误:I/O 错误、配置错误、网络错误、业务规则错误、数据质量错误。它们不能继续以散乱字符串、隐式包装和边界泄露的形式到处作乱,而必须被纳入一套可命名、可诊断、可约束、可演进的治理体系。

```text
错误内部模型 = 契约通道 + 诊断通道
适配输出 = 对内部模型按策略生成的不同输出视图
```

**契约通道**:包含稳定错误标识、稳定分类、category、retryable、暴露等级。服务调用方、网关、监控、运维策略。稳定性要求高,应被文档化和测试约束。

**诊断通道**:包含原因链、操作上下文、关键字段、动态细节、底层错误。服务开发者、SRE、排障工具。可以动态变化,但必须详实可靠且可追溯。

HTTP response、RPC error、CLI output、log record、metric label 和 debug report 不是错误本体,而是错误到达不同边界后生成的**适配输出**。同一个错误,通过不同输出视图服务不同角色:调用方看到的是 `order.submit_dependency_unavailable` 和它的治理属性;排障方看到的是完整的 cause chain:`service -> repository -> database timeout`,以及每层的 detail、context 和原始错误。

这里有四个概念要分清:

- `identity` 是长期稳定的机器主键,面向协议、监控、告警、文档和兼容性。
- `reason` 是代码里的领域分类表达,比如 Rust enum、Java sealed class 或 tagged union。
- `category` 是粗粒度治理维度,用来辅助路由、聚合和告警。
- `policy` 是边界输出规则,决定 HTTP 状态码、visibility、retryable、hints、日志级别等。

外部系统应该依赖 `identity.code`,而不是 Rust enum 名、Java class 名或错误文案。`reason` 是进程内代码表达,`identity` 才是跨边界、跨版本的契约。

这不是一个 API 问题,而是一组围绕失败信息展开的工程约束。

还有一个很实用的判断:不同信息的稳定性不同。`identity.code` 最稳定,适合作为外部契约;`category` 通常也稳定;`reason` 变体主要是代码内契约;`detail`、context 字段、source chain 都更动态,不应该被外部调用方依赖。所以测试应该断言 identity 和策略结果,而不是断言错误文案。

---

## 落地需要五项原则

概念说清楚了,但怎么落地?我在不同项目里反复踩坑之后,把这套模型收敛成五项可操作的原则。

### 原则一:用统一载体承载契约与诊断信息

自有跨层错误传播路径应使用统一的结构模型。不是要求所有第三方库都改成同一种类型,而是你团队控制的内部传播路径,使用同一种形状。

变化的是分类空间和上下文,不是错误形状本身。

> 反例:controller 返回 `Result<T, Box<dyn Error>>`,service 返回 `Result<T, MyEnum>`,dao 返回 `io::Result<T>`。三种形状,调用方每层都要重新学习一套规则。

### 原则二:让契约通道保持稳定

错误分类是契约。调用方依赖它做治理决策。可以新增错误标识,但不应删除已承诺的、不应改变已有语义、不应让同一错误标识在不同边界产生矛盾的治理动作。

稳定错误标识是人和系统之间的共享接口:运维配告警、网关配状态码、API 文档描述错误响应,全部依赖错误标识而非错误消息文本。

> 反例:把 `business.not_found` 的语义从"资源不存在"悄悄改成"无权限访问"。所有依赖这个错误标识做 404 映射的上游都会出错。

新增 identity 也要克制。只有当调用方需要不同治理动作、边界需要不同状态码或用户提示、监控需要独立聚合、语义能长期稳定时,才值得新增稳定错误标识。字段名、文件路径、租户、行列号、底层库错误文本这些动态差异,应进入诊断通道,而不是膨胀契约通道。

### 原则三:让诊断通道跨层保留有效信息

错误在内部传播时应追加信息,不应破坏已有诊断链。判断标准是:当前层是否跨越了语义域?

同语义域内(比如 database driver -> query executor -> repository helper 都在 data access 域),只需分类收敛 + 诊断保留。跨语义域时(比如 data access -> order service),建立新语义边界,把下层错误作为 cause 保留。

> 反例:service 层调用 repository 失败,直接 `return Err(ServiceError::DependencyFailed)` 而丢弃了下层的 database timeout 根因。排障时只能看到 service 这一层,根因永远丢失。

在 Rust / `orion-error` 的心智模型里,可以这样判断:只把下层 reason 收敛到上层 reason,用 `conv_err()`;跨语义域、要表达新的业务失败,用 `source_err(...)`;只是补充当前操作路径和字段,用 `doing(...)` / `with_context(...)`。不要把每一层都包装成新的错误叙事,也不要把底层技术失败直接暴露给上层。

### 原则四:在边界集中输出

边界暴露策略应集中定义。HTTP 状态码、RPC 错误码、日志级别、告警触发、重试建议、脱敏规则,如果在每个 handler 里重新做一次,同一错误标识就会在不同边界产生不同表现。

集中策略让错误标识 -> 治理动作的映射变成一处定义、处处一致。与其在每个 handler 里 `match err { ... }` 决定状态码,不如定义一个统一的 `exposure` 策略,所有边界点都调用同一套规则。handler 只负责传错误、取结果。

> 反例:两个 handler 对同一个 `system.timeout` 错误,A 返回 503,B 返回 504。客户端重试逻辑不知道该信谁。

### 原则五:显式桥接外部生态

进入外部生态(日志系统、标准错误接口、第三方库)应是显式的。每次桥接都要回答:目标消费者是谁?保留什么?丢弃什么?脱敏什么?如何降级?

信息不一定要全带出去,但每次输出都应该是**可审计、可测试、可预期**的。

> 反例:调用方在不知情的情况下把 `StructuredError` 转成字符串传给日志系统。诊断链、错误标识、上下文全部擦除,除了几行文案什么都没留下。

尤其要区分信任域:进程内可以保留完整结构化错误;服务间更适合传递协议快照、identity、category、公开消息、retryable 和 correlation id;用户边界只应该看到稳定错误码、可公开消息和必要修复提示;观测系统则接收脱敏后的诊断摘要和聚合标签。

---

## 错误传播的三种模式

一个工业级错误经历三种动作,不是机械向上抛。

**首次进入。** 原始错误(I/O、网络、解析)首次进入结构化体系,同时完成:选择分类、给出当前层解释(detail)、保留原始错误为 source。

**跨层转换。** 同语义域收敛分类,跨语义域建立新边界包裹。取决于当前层是否是新语义边界。

**适配输出。** 在系统边界交给集中策略,为不同接收端生成不同输出视图。不再重新解释错误。

三层下来,契约通道给调用方的是稳定错误标识和治理决策,诊断通道保留了完整的 cause chain + context + detail。排障方能追溯根因,调用方不需要知道底层细节。

---

如果把上篇的方法论压缩成一句话,就是:

**错误不是局部控制流细节,而是工业级系统的信息架构。**

下篇继续进入 Rust 落地:如何划分语义域,如何定义 `Reason`,如何处理首次进入、跨层转换和边界输出,以及 `orion-error` 为什么会设计成现在这个样子。

这篇文章整理自 `orion-error` 项目文档中的长文版本,知乎这里做了分篇和轻量整理。