orion-error 中文文档
orion-error 就是 WuKong 错误治理模型在 Rust 中的一种工程实现。
在文档首页,最重要的定位先说明清楚:
- 契约通道:稳定 identity、category、retryable、visibility
- 诊断通道:detail、source chain、操作上下文、关键字段
- 适配输出:按策略生成 HTTP / RPC / CLI / 日志投影视图
在这个 crate 里,这些理念落到:
- 用
#[derive(OrionError)]定义稳定语义身份 - 用
StructError<R>作为统一运行时载体 - 用
source_err(...)处理首次进入和新语义边界包装 - 用
conv_err()做 reason 收敛,不重写错误叙事 - 用
report()/identity_snapshot()/exposure(...)做边界输出
orion-error 面向 Rust 服务中的结构化错误治理:让错误在跨层传播时保留稳定身份、上下文、来源链、日志材料和协议暴露视图,而不是退化成不可治理的字符串。
推荐先阅读“为什么需要 orion-error”,再进入教程和协议文档。
用户文档
| 文档 | 内容 |
|---|---|
| 为什么需要 orion-error | 解释错误治理的核心问题:环境信息、技术细节抽象、错误链、日志和多视图呈现 |
| 使用教程 | 从定义 reason、构造 StructError、使用 source_err / conv_err 到输出报告 |
| OrionError 与稳定身份 | 说明 ErrorIdentity.code、业务 reason、透明 UvsReason 变体的设计 |
| 协议契约 | 说明 HTTP / RPC / CLI / log 投影的稳定边界 |
| Report / Exposure 边界 | 区分内部诊断报告和对外暴露视图 |
| 日志说明 | 说明如何在错误边界输出有效日志,避免到处散落日志代码 |
| 生态方案对比 | 对比 anyhow、thiserror、color-eyre 和 orion-error 的适用边界 |
| 与 thiserror 的关系 | 说明两者不是简单替代关系,分别适合不同层级 |
| 大型工程错误治理宣言 | WuKong 模型、治理原则与工业级验证 |
| 设计约束 | 说明 orphan rule 等 Rust 语言约束下的 API 取舍 |
开发文档
| 文档 | 内容 |
|---|---|
| API Contract | 0.8 公共 API、分层模块、feature-gated API 和稳定快照契约 |
| 兼容与迁移 | 旧 API 到当前 API 的迁移说明 |
| Public Surface Grading | 公共暴露面的分级评估 |
| Release Checklist | 发布前检查项 |
| StructError Allocation | 分配行为与性能记录 |
| StructError Source Debug | source debug 路径的性能记录 |
当前主路径 API
新代码优先使用:
reason.to_err():把单个 reason 转成StructErrorresult.source_err(reason, detail):让普通错误进入结构化错误系统,或建立新的上层语义边界result.conv_err():对已有StructError<R1>做 reason-only 类型转换err.with_source(source)/StructError::builder(reason).source(source):自动识别 raw std error 或下层StructErrorsourceOperationContext::doing(...).with_field(...).with_meta(...):链式携带结构化上下文
稳定外部身份使用 ErrorIdentity.code。ErrorCode 是兼容数字码,不应作为主要治理身份。
English
English documentation starts from orion-error Documentation.
为什么需要 orion-error
orion-error 不是为了把错误打印得更漂亮,而是为了让 Rust 服务中的错误成为可治理、可追踪、可暴露、可演进的结构化契约。
普通错误处理回答的是“这段代码如何返回失败”。大型服务还需要回答:
- 出错时如何携带关键环境信息?
- 技术细节错误如何抽象成上层合理语义,同时不丢诊断信息?
- 排错时如何看到跨层错误传递链?
- 如何在失败边界输出有效日志,而不是到处写日志?
- 同一个错误如何给用户、运维、开发者和协议客户端呈现不同视图?
orion-error 的核心价值是:让错误在跨层传播时保留结构,而不是退化成字符串。
1. 诊断
1.1 错误本身经常缺少关键环境信息
很多底层错误只告诉你“发生了什么技术失败”,但不会告诉你“失败发生在哪个业务环境里”。
例如:
#![allow(unused)]
fn main() {
let content = std::fs::read_to_string(path)?;
}
如果读取失败,底层 std::io::Error 可能只告诉你:
No such file or directory
但排障真正需要的问题通常是:
- 读取的是哪个路径?
- 当前正在执行什么操作?
- 这个路径属于哪个租户、订单、请求或组件?
- 它是配置文件、订单记录、缓存文件,还是临时文件?
- 这个失败应该被归类为配置错误、系统错误,还是业务校验错误?
不够好的做法:只在日志里补字符串
#![allow(unused)]
fn main() {
match std::fs::read_to_string(path) {
Ok(content) => Ok(content),
Err(err) => {
log::error!("read config failed, path={path}, error={err}");
Err(err)
}
}
}
这种方式的问题是:
- 日志和错误对象是两套信息。
- 上层拿到的错误仍然没有结构化上下文。
- 如果边界还要输出 HTTP/RPC/CLI 错误,仍然需要重新组织字段。
- 多层代码很容易重复打印同一个失败。
推荐做法:错误产生时就携带结构化上下文
#![allow(unused)]
fn main() {
use orion_error::prelude::*;
use orion_error::runtime::OperationContext;
let ctx = OperationContext::doing("load config")
.with_field("path", path.display().to_string())
.with_meta("component.name", "config_loader");
let content = std::fs::read_to_string(path)
.source_err(AppReason::system_error(), "read config failed")?
.with_context(&ctx);
}
这里的语义是:
source_err(...)把底层std::io::Error接入结构化错误系统。AppReason::system_error()是上层可以理解的稳定错误语义。"read config failed"是当前层对失败的解释。OperationContext携带path和component.name等关键环境信息。
1.2 技术细节错误需要抽象,但不能丢诊断信息
跨层传播技术错误时,常见两个坏选择:
- 在下层丢弃具体错误。
- 把具体错误直接暴露给上层,让 service / API 层依赖数据库、HTTP client、文件系统或解析器实现。
这两种方案都不合适。
正确做法是:在层边界把下层错误转换/抽象成当前层合理的错误语义,同时保留排障所需的 source、detail 和 context,并切断上层对具体技术实现的依赖。
不够好的做法:让 service 层依赖 repository 的技术错误
#![allow(unused)]
fn main() {
async fn submit_order(order: Order) -> Result<(), sqlx::Error> {
repository::insert_order(order).await?;
Ok(())
}
}
这会让 service/API 层知道底层使用了 sqlx。将来 repository 从 PostgreSQL 改成对象存储、消息队列或远程服务时,上层错误契约也会被迫变化。
不够好的做法:丢掉底层错误
#![allow(unused)]
fn main() {
async fn submit_order(order: Order) -> Result<(), StoreError> {
if repository::insert_order(order).await.is_err() {
return Err(StoreReason::Unavailable.to_err());
}
Ok(())
}
}
这看起来切断了技术依赖,但也丢掉了根因。排障时只知道“存储不可用”,不知道是连接超时、唯一键冲突、序列化失败,还是磁盘满。
推荐做法:抽象到当前层 reason,同时保留下层 source
#![allow(unused)]
fn main() {
use orion_error::prelude::*;
async fn write_order(order: Order) -> Result<(), StructError<StoreReason>> {
repository::insert_order(&order)
.await
.source_err(StoreReason::Unavailable, "insert order failed")?
.with_field("order_id", order.id.to_string())
.with_meta("component.name", "order_store");
Ok(())
}
}
这里:
- 上层看到的是
StructError<StoreReason>,不是sqlx::Error。 StoreReason::Unavailable是存储层合理的稳定语义。- 原始数据库错误仍然作为 source 保留在内部诊断链里。
order_id、component.name等字段用于排障和日志投影。
如果上层只需要把 StoreReason 收敛成 AppReason,但不想创建新的语义边界,可以使用 conv_err():
#![allow(unused)]
fn main() {
async fn submit_order(order: Order) -> Result<(), StructError<AppReason>> {
write_order(order).await.conv_err()?;
Ok(())
}
}
如果上层确实要建立新的业务语义边界,例如“提交订单失败”,则使用 source_err(...) 保留下层结构化错误作为 source:
#![allow(unused)]
fn main() {
async fn submit_order(order: Order) -> Result<(), StructError<AppReason>> {
write_order(order)
.await
.source_err(AppReason::system_error(), "submit order failed")?;
Ok(())
}
}
这两个方法表达的语义不同:
conv_err():只做 reason 映射,不新增边界。source_err(reason, detail):建立新的语义边界,并保留下层错误链。
1.3 排错需要错误传递链,而不是孤立错误
真实故障很少只发生在一个函数里。一个最终错误通常经历多层传递:
HTTP handler
-> service
-> repository
-> database / filesystem / remote API
如果每一层都只是替换成新的字符串,排错时只能看到最终错误:
submit order failed
但真正有价值的是它如何一路变成这个错误:
submit order failed
caused by: insert order failed
caused by: database request failed
caused by: connection timed out
错误传递链能回答这些问题:
- 最初的技术失败是什么?
- 失败经过了哪些业务层?
- 哪一层建立了新的语义边界?
- 每一层添加了哪些上下文?
- 最终对外暴露的错误,与内部真实原因是什么关系?
推荐做法:每个边界都保留 source chain
#![allow(unused)]
fn main() {
async fn adapter_call(req: Request) -> Result<Response, StructError<AdapterReason>> {
client.send(req)
.await
.source_err(AdapterReason::RemoteUnavailable, "remote call failed")
}
async fn load_quote(id: QuoteId) -> Result<Quote, StructError<ServiceReason>> {
adapter_call(Request::quote(id))
.await
.source_err(ServiceReason::QuoteLoadFailed, "load quote failed")?
.with_field("quote_id", id.to_string());
todo!("map response")
}
}
这不是简单的“包装一层字符串”。它保留了:
- service 层语义:
QuoteLoadFailed - adapter 层语义:
RemoteUnavailable - 底层 source:HTTP client / IO / timeout 等具体错误
- 结构化字段:
quote_id
排错时可以从最终错误沿 source chain 追溯到根因;协议边界则可以只暴露安全、稳定的上层身份。
2. 运维
有效日志不是大量日志,而是边界统一记录
很多系统排错困难,不是因为日志太少,而是因为日志方式不对:
- 每一层都
error!一次,产生重复日志。 - 日志只有字符串,没有稳定字段。
- 为了补上下文,到处写
path={path}、tenant={tenant}、order_id={order_id}。 - 业务代码被日志拼接污染。
- 日志和错误对象携带的信息不一致。
更合理的方式是:错误在传播过程中携带结构化 context、source chain、reason 和 stable identity;日志只在 handler、worker、任务边界统一记录一次。
不够好的做法:每一层都打印一次
#![allow(unused)]
fn main() {
log::error!("repository insert failed: {err}");
log::error!("service submit failed: {err}");
log::error!("http request failed: {err}");
}
这种方式会带来大量重复日志。排障人员需要从多条日志里重新拼出一条错误路径,字段也容易不统一。
推荐做法:错误携带信息,边界统一投影
#![allow(unused)]
fn main() {
async fn handle_submit(order: Order) -> Result<HttpResponse, StructError<AppReason>> {
submit_order(order)
.await
.source_err(AppReason::system_error(), "handle submit order failed")?;
Ok(HttpResponse::ok())
}
}
边界处可以基于同一个错误对象输出不同形式:
#![allow(unused)]
fn main() {
let report = err.report();
let exposure = err.exposure(&policy);
}
这意味着:
- 业务层不需要到处拼日志字符串。
- 日志可以统一包含
identity、reason、detail、context、source chain。 - 边界只记录一次失败,避免重复噪声。
- 结构化字段可以被日志系统、监控系统和告警系统查询。
OperationContext 日志
OperationContext 提供结构化日志方法,输出时自动带上当前操作的 field 和 metadata:
#![allow(unused)]
fn main() {
use orion_error::OperationContext;
let ctx = OperationContext::doing("order_processing")
.with_field("order_id", "123")
.with_meta("component.name", "order_service");
ctx.info("start");
ctx.warn("slow upstream");
ctx.error("final failure");
}
对于需要生命周期日志的作用域,使用 with_auto_log():
#![allow(unused)]
fn main() {
let mut ctx = OperationContext::doing("sync_user")
.with_auto_log()
.with_field("user_id", "42");
do_sync()?;
ctx.mark_suc();
}
如果作用域在 Drop 前没有标记成功或取消,自动输出失败日志。更详细的用法参考 日志说明。
推荐原则:少量生命周期日志 + 边界错误投影,而不是每层重复 error!。
3. 呈现
同一个错误需要面向不同对象呈现不同视图
真实系统里的错误不是只给一种人看的。
至少有几类接收者:
- 最终使用者:需要安全、可理解、可行动的信息。
- 系统调整者 / 运维 / SRE:需要组件、环境、分类、重试和影响判断。
- 开发者:需要 source chain、detail、上下文和底层错误。
- 客户端 / 上游系统:需要稳定
code、字段结构、retry hint 和协议形状。 - 日志 / 监控 / 告警系统:需要结构化字段,而不是长字符串。
如果只有一个错误字符串,很难同时满足这些对象。
例如:
database connection failed: timeout from sqlx pool
这个信息:
- 给用户看太技术化。
- 给开发者看又缺少业务上下文。
- 给协议客户端看不稳定。
- 给日志系统看不可结构化查询。
推荐做法:内部保留完整结构,边界投影不同视图
orion-error 把“错误内部结构”和“外部呈现”分开:
- 内部保留
reason、ErrorIdentity.code、detail、context、source chain - 面向用户时,只暴露安全、可理解、可行动的信息
- 面向系统调整者时,暴露组件、操作、分类、重试、严重性等治理信息
- 面向开发者时,使用 report 查看完整诊断链
- 面向协议时,使用 exposure 形成稳定字段结构
#![allow(unused)]
fn main() {
let report = err.report();
let exposed = err.exposure(&DefaultExposurePolicy::default());
}
同一个错误对象可以投影成不同用途:
| 对象 | 需要的信息 | 推荐投影 |
|---|---|---|
| 用户 | 安全 message、可行动提示 | exposure view |
| 运维 / SRE | component、operation、retryable、severity | exposure snapshot / log JSON |
| 开发者 | source chain、detail、context | report |
| 协议客户端 | stable code、字段结构、retry hint | HTTP/RPC/CLI error JSON |
| 测试 / 回归 | 稳定结构快照 | stable snapshot |
这也是 orion-error 与单纯展示层工具的关键区别:它不是只把错误显示得更漂亮,而是保留一个结构化错误,再按边界需求投影不同视图。
总结
orion-error 适合的不是“最少代码返回一个错误”,而是“让错误成为系统契约”。
它解决的核心问题是:
- 补齐错误发生环境:底层错误不会自动携带 path、tenant、order_id、operation 等关键上下文。
- 抽象技术细节而不丢诊断信息:在层边界把技术失败转换成稳定领域语义,同时保留 source 和 context。
- 保留跨层错误传递链:让排错看到失败如何从底层一路被解释成最终边界错误。
- 让日志有效而克制:错误对象携带结构化信息,边界统一记录,减少重复日志和业务代码里的日志拼接。
- 按对象呈现不同错误视图:同一个错误,面向用户、运维、开发者、协议客户端和日志系统,应有不同粒度和安全等级的输出。
一句话:
orion-error让错误在跨层流转中保持结构化,并在不同边界呈现正确视图。
使用教程
本文档以当前源码、测试和 examples/ 为准,描述 orion-error 的主路径用法。
安装
[dependencies]
orion-error = "0.8.0"
常见可选 feature:
[dependencies]
orion-error = { version = "0.8.0", features = ["serde"] }
# 或
orion-error = { version = "0.8.0", features = ["tracing"] }
# 或
orion-error = { version = "0.8.0", features = ["serde_json"] }
默认 feature 包含:
derivelog
导入约定
推荐优先使用下面两种方式:
#![allow(unused)]
fn main() {
use orion_error::prelude::*;
use orion_error::runtime::OperationContext;
}
或:
#![allow(unused)]
fn main() {
use orion_error::{StructError, OrionError};
use orion_error::conversion::{ErrorWith, SourceErr, ConvErr};
use orion_error::protocol::DefaultExposurePolicy;
use orion_error::reason::UnifiedReason;
use orion_error::runtime::OperationContext;
}
其中:
prelude::*只导出主路径:OrionError、StructError、SourceErr、ErrorWith、ConvErr- 新业务代码默认先用
prelude::*;只有在模块要显式表达 runtime / conversion / protocol 等边界时,再补 layered imports DefaultExposurePolicy只从protocol::*导入,因为它只属于 exposure/projection 边界- 需要更明确边界时,再按职责补
runtime/conversion/report/bridge/reason/protocol
一分钟上手
#![allow(unused)]
fn main() {
use derive_more::From;
use orion_error::{
prelude::*,
runtime::OperationContext,
};
#[derive(Debug, Clone, PartialEq, From, OrionError)]
enum AppReason {
#[orion_error(identity = "biz.invalid_request")]
InvalidRequest,
#[orion_error(transparent)]
General(UnifiedReason),
}
fn load_config() -> Result<String, StructError<AppReason>> {
let ctx = OperationContext::doing("load config")
.with_field("path", "config.toml")
.with_meta("component.name", "config_loader");
std::fs::read_to_string("config.toml")
.source_err(AppReason::system_error(), "read config failed")
.doing("read config file")
.with_context(&ctx)
}
}
这个例子覆盖了当前主路径的四个核心点:
- 领域 reason 用
OrionError定义 - 错误进入结构化体系用
source_err(...)(统一入口) - 运行时语义上下文用
doing(...) - 诊断字段和 metadata 写到
OperationContext
1. 定义 reason
1.1 领域 reason
新代码推荐直接 derive OrionError:
#![allow(unused)]
fn main() {
use derive_more::From;
use orion_error::{OrionError, UnifiedReason};
#[derive(Debug, Clone, PartialEq, From, OrionError)]
enum OrderReason {
#[orion_error(identity = "biz.order_not_found")]
OrderNotFound,
#[orion_error(identity = "biz.insufficient_funds")]
InsufficientFunds,
#[orion_error(transparent)]
General(UnifiedReason),
}
}
OrionError 会为该类型生成:
DisplayDomainReasonErrorCodeErrorIdentityProvider
默认规则:
identity = "biz.order_not_found"生成 stable codecategory默认由identity前缀推导message未显式指定时,会从identity最后一段推导出显示文案code未显式指定时,兼容数值码默认是500
1.2 通用 reason
UnifiedReason 是 crate 内置的通用错误分类,已经实现:
DomainReasonErrorCodeErrorIdentityProvider
常用构造:
UnifiedReason::validation_error()UnifiedReason::business_error()UnifiedReason::system_error()UnifiedReason::network_error()UnifiedReason::timeout_error()UnifiedReason::core_conf()UnifiedReason::logic_error()
2. 构造 StructError
2.1 直接构造
#![allow(unused)]
fn main() {
use orion_error::{StructError, UnifiedReason};
let err = StructError::from(UnifiedReason::validation_error())
.with_detail("field `email` is required");
}
2.2 Builder 构造
#![allow(unused)]
fn main() {
use orion_error::{
runtime::OperationContext,
StructError,
UnifiedReason,
};
let ctx = OperationContext::doing("validate request");
let err = StructError::builder(UnifiedReason::validation_error())
.detail("field `email` is required")
.context_ref(&ctx)
.finish();
}
2.3 挂载 source
已有 StructError 时:
#![allow(unused)]
fn main() {
use orion_error::{StructError, UnifiedReason};
let err = StructError::from(UnifiedReason::system_error())
.with_detail("read config failed")
.with_source(std::io::Error::other("disk offline"));
assert_eq!(err.source_ref().unwrap().to_string(), "disk offline");
}
Builder 时:
#![allow(unused)]
fn main() {
use orion_error::{StructError, UnifiedReason};
let err = StructError::builder(UnifiedReason::system_error())
.detail("read config failed")
.source(std::io::Error::other("disk offline"))
.finish();
assert_eq!(err.source_ref().unwrap().to_string(), "disk offline");
}
主路径建议优先使用:
with_source(...)source(...)
它们会自动处理:
- 普通
StdError - 已结构化的
StructError<_>
下面这些显式 API 属于底层/诊断/测试入口,不作为新业务代码主路径:
with_std_source(...)with_struct_source(...)source_std(...)source_struct(...)
3. 使用上下文
OperationContext 是运行时上下文载体。
#![allow(unused)]
fn main() {
use orion_error::OperationContext;
let ctx = OperationContext::doing("place_order")
.with_field("order_id", "A-1001")
.with_field("user_id", "42")
.with_meta("component.name", "order_service")
.with_meta("tenant.id", "demo");
}
推荐区分两类写法:
with_field(...):给人看的诊断字段(chain 模式)with_meta(...):机器消费的结构化 metadata(chain 模式)record_field(...)/record_meta(...):当已有可变引用时使用
3.1 错误侧挂载上下文
#![allow(unused)]
fn main() {
use orion_error::prelude::*;
use orion_error::{OperationContext, StructError, UnifiedReason};
fn check_inventory() -> Result<(), StructError<UnifiedReason>> {
Err(StructError::from(UnifiedReason::business_error()).with_detail("inventory unavailable"))
}
let mut ctx = OperationContext::doing("place_order");
ctx.record_field("order_id", "A-1001");
let result = check_inventory()
.doing("check inventory")
.with_context(&ctx);
assert!(result.is_err());
}
上下文语义:
OperationContext::doing(...)写actionOperationContext::at(...)写locatorStructError::doing(...)/at(...)是对应的 error-side 语义糖衣- 兼容投影仍然保留
target/path
常用读取方法:
action_main()locator_main()target_path()
4. 错误进入和跨层转换
4.1 source_err(reason, detail)
source_err(reason, detail) 是统一入口,同时支持原始 std::error::Error 和已结构化的 StructError<_> 源。
#![allow(unused)]
fn main() {
use orion_error::prelude::*;
let err = std::fs::read_to_string("config.toml")
.source_err(UnifiedReason::system_error(), "read config failed")
.unwrap_err();
}
source_err 支持常见的标准错误类型和已结构化的 StructError 源。
当前支持的是一组受控入口:
std::io::Erroranyhow::Error(启用anyhowfeature)serde_json::Error(启用serde_jsonfeature)toml::de::Error/toml::ser::Error(启用tomlfeature)raw_source(...)包装后的下游自定义RawStdError
如果你有第三方错误类型,需要显式 opt-in:
#![allow(unused)]
fn main() {
use std::fmt;
use orion_error::prelude::*;
use orion_error::interop::{raw_source, RawStdError};
#[derive(Debug)]
struct ThirdPartyError;
impl fmt::Display for ThirdPartyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "third-party failure")
}
}
impl std::error::Error for ThirdPartyError {}
impl RawStdError for ThirdPartyError {}
let result: Result<(), ThirdPartyError> = Err(ThirdPartyError);
let err = result
.map_err(raw_source)
.source_err(UnifiedReason::system_error(), "load failed")
.unwrap_err();
}
4.2 conv_err()
当只是把下层 reason 收敛到上层 reason,而不想新增一层 detail/source 语义时,使用 conv_err():
#![allow(unused)]
fn main() {
use derive_more::From;
use orion_error::{OrionError, StructError, UnifiedReason};
use orion_error::conversion::ConvErr;
use orion_error::conversion::ToStructError;
#[derive(Debug, Clone, PartialEq, From, OrionError)]
enum RepoReason {
#[orion_error(transparent)]
General(UnifiedReason),
}
#[derive(Debug, Clone, PartialEq, From, OrionError)]
enum ServiceReason {
#[orion_error(transparent)]
Repo(RepoReason),
}
fn lower_layer_call() -> Result<(), StructError<RepoReason>> {
Err(RepoReason::system_error().to_err()
.with_detail("read config failed"))
}
fn upper_layer_call() -> Result<(), StructError<ServiceReason>> {
lower_layer_call().conv_err()?;
Ok(())
}
let err = upper_layer_call().unwrap_err();
assert_eq!(err.detail().as_deref(), Some("read config failed"));
}
典型前提是:
R2: From<R1>
5. source、report、bridge 的边界
5.1 运行时对象
运行时传播使用:
StructError<R>
5.2 人类诊断对象
人类诊断使用:
DiagnosticReport
常用入口:
#![allow(unused)]
fn main() {
use orion_error::{StructError, UnifiedReason};
let err = StructError::from(UnifiedReason::system_error())
.with_detail("read config failed");
let report = err.report();
assert_eq!(report.reason(), "system error");
}
5.4 标准错误生态 bridge
StructError<R> 本身不再直接实现 std::error::Error。
需要进入标准错误生态时,使用显式 bridge:
as_std()into_std()into_boxed_std()into_dyn_std()
6. 稳定身份和协议投影
6.1 稳定身份
每个错误变体都有一个永久的机器可读名称,不随文案或重构改变:
#![allow(unused)]
fn main() {
use orion_error::{OrionError, StructError};
use orion_error::reason::ErrorIdentityProvider;
#[derive(Debug, PartialEq, OrionError)]
enum ApiReason {
#[orion_error(identity = "biz.invalid_input")]
InvalidInput,
}
// 这个字符串是契约——监控、客户端、网关都依赖它:
assert_eq!(ApiReason::InvalidInput.stable_code(), "biz.invalid_input");
assert_eq!(ApiReason::InvalidInput.error_category().as_str(), "biz");
}
对比不稳定 vs 稳定:
| 不稳定 | 稳定 |
|---|---|
"invalid input"(显示文案可能改) | "biz.invalid_input"(永久) |
100(数值码可能冲突) | "biz.invalid_input"(带命名空间) |
ApiReason::InvalidInput(Rust 路径可能重构) | "biz.invalid_input"(独立于源代码) |
biz . invalid_input
──── ────────────
category stable code
(conf/biz 不变的业务语义
/logic/sys)
6.2 协议投影
同一个错误,对不同的协议边界输出不同的 JSON 形状,不需要手写映射:
use orion_error::{OrionError, StructError};
use orion_error::protocol::DefaultExposurePolicy;
use orion_error::UnifiedReason;
#[derive(Debug, PartialEq, OrionError)]
enum ApiReason {
#[orion_error(identity = "biz.invalid_input")]
InvalidInput,
#[orion_error(transparent)]
General(UnifiedReason),
}
let err = StructError::from(ApiReason::system_error())
.with_detail("disk offline at /dev/sda");
let proto = err.exposure(&DefaultExposurePolicy);
// HTTP 响应——最小字段,对外安全
let http = proto.to_http_error_json().unwrap();
assert_eq!(http["status"], 500); // 内部错误
assert_eq!(http["message"], "system error"); // 用 reason,不用 detail
// 日志输出——完整上下文,方便排查
let log = proto.to_log_error_json().unwrap();
assert_eq!(log["detail"], "disk offline at /dev/sda"); // 完整 detail
assert!(log["source_frames"].is_array()); // source 链
// RPC 响应——隐藏内部细节
let rpc = proto.to_rpc_error_json().unwrap();
assert!(rpc["detail"].is_null()); // internal → 隐藏 detail
// CLI 输出——人类可读摘要
let cli = proto.to_cli_error_json().unwrap();
assert_eq!(cli["summary"], "system error: disk offline at /dev/sda");
核心概念:错误是一个三维物体,每个协议边界看到的是它投下的不同形状的影子。ExposurePolicy 决定哪一面对外可见。
错误本身(StructError<R>)
│
┌─────────┼──────────┐
│ │ │
▼ ▼ ▼
HTTP RPC Log
{status, {code, {code, detail,
message} detail} source_frames}
6.3 入口选择
identity_snapshot():查看稳定身份exposure(...):完整协议输入(identity + decision + report)to_*_error_json():协议边界出口 JSON
7. 测试建议
当前测试 helper:
assert_err_code(...)assert_err_category(...)assert_err_identity(...)assert_err_operation(...)assert_err_path(...)
这里的 assert_err_code(...) 断言的是 stable code 字符串,不是数值 error_code()。
示例:
#![allow(unused)]
fn main() {
use orion_error::prelude::*;
use orion_error::reason::ErrorCategory;
use orion_error::dev::testing::assert_err_identity;
let err = std::fs::read_to_string("config.toml")
.source_err(UnifiedReason::system_error(), "read config failed")
.unwrap_err();
assert_err_identity(&err, "sys.io_error", ErrorCategory::Sys);
}
如果你要断言数值码,请直接调用:
#![allow(unused)]
fn main() {
use orion_error::reason::ErrorCode;
use orion_error::{StructError, UnifiedReason};
let err = StructError::from(UnifiedReason::system_error());
assert_eq!(err.reason().error_code(), 201);
}
8. 推荐实践
- 领域 reason 默认 derive
OrionError - 对外稳定协议依赖 stable code,不依赖人类文案
- 所有错误统一使用
source_err(...)进入结构化体系 - 只做 reason 收敛优先
conv_err() - 需要协议暴露时使用
exposure(&policy) - 需要对外协议时使用
exposure(...)或 projection API - 需要进入标准错误生态时使用显式 interop API
OrionError 与稳定身份
本文解释当前实现里的四个概念:
DomainReasonOrionErrorErrorCodeErrorIdentityProvider
核心结论先放在前面:
DomainReason解决“这个 reason 能不能作为StructError<R>的运行时语义载体”OrionError解决“这个 reason 能不能低成本地同时获得显示文案、兼容数值码和稳定身份”ErrorCode是兼容数值码ErrorIdentityProvider才是对外稳定协议的机器主键来源
1. 当前 trait 约束
当前 DomainReason 很薄,只要求:
pub trait DomainReason: PartialEq + Display + Debug + Send + Sync + 'static {}
它只负责说明:
StructError<R>的 reason 类型是显式的- reason 本身有可显示文本
DomainReason 本身不包含:
- 稳定 code
- category
- exposure 决策
- 对外协议投影
2. OrionError 做了什么
推荐写法:
#![allow(unused)]
fn main() {
use derive_more::From;
use orion_error::{OrionError, UnifiedReason};
#[derive(Debug, Clone, PartialEq, From, OrionError)]
enum AppReason {
#[orion_error(identity = "biz.invalid_request")]
InvalidRequest,
#[orion_error(transparent)]
General(UnifiedReason),
}
}
OrionError 会同时生成:
DisplayDomainReasonErrorCodeErrorIdentityProvider
因此它是当前定义领域 reason 的推荐入口。
3. ErrorCode 与 ErrorIdentityProvider 的区别
3.1 ErrorCode
ErrorCode::error_code() 返回兼容数值码:
UnifiedReason::system_error()是201OrionError未显式声明code = ...时默认是500
它的定位是:
- 兼容旧系统
- 兼容已有数值码测试
- 保留传统集成接口
3.2 ErrorIdentityProvider
ErrorIdentityProvider 提供两个稳定协议字段:
stable_code()error_category()
例如:
sys.io_errorbiz.invalid_requestlogic.internal_invariant_broken
它们用于:
- 稳定断言
- exposure 决策
- HTTP / CLI / log / RPC 投影
- 指标、聚合、告警、跨服务协作
如果系统要依赖稳定机器主键,应该依赖这里,而不是 Display 文案。
4. identity 注解规则
4.1 最常用写法
#![allow(unused)]
fn main() {
use orion_error::OrionError;
#[derive(Debug, Clone, PartialEq, OrionError)]
enum AppReason {
#[orion_error(identity = "biz.order_not_found")]
OrderNotFound,
}
}
这会产生:
- stable code:
biz.order_not_found - 默认显示文案:
order not found - category:
Biz - 默认兼容数值码:
500
4.2 显式指定 message / code / category
#![allow(unused)]
fn main() {
use orion_error::OrionError;
#[derive(Debug, Clone, PartialEq, OrionError)]
enum AppReason {
#[orion_error(
message = "storage temporarily unavailable",
code = 2000,
identity = "sys.storage_unavailable",
category = Sys
)]
StorageUnavailable,
}
}
这里:
message控制Displaycode控制兼容数值码identity控制 stable codecategory可显式覆盖,也可由 identity 前缀推导
4.3 transparent
#![allow(unused)]
fn main() {
use orion_error::{OrionError, UnifiedReason};
#[derive(Debug, Clone, PartialEq, OrionError)]
enum AppReason {
#[orion_error(transparent)]
General(UnifiedReason),
}
}
transparent 会把以下能力委托给内部单字段类型:
DisplayErrorCodeErrorIdentityProvider
这适合保留一个 General(UnifiedReason) 兜底通道。
5. 为什么不能只靠 Display
Display 适合给人看,不适合当协议主键。
原因:
- 文案可能优化
- 文案可能国际化
- 文案可能带动态值
- 不同调用方很难稳定依赖自然语言文本
例如:
read config failedfailed to read config读取配置失败
这些都可能描述同一类错误,但它们不适合做稳定协议主键。
相比之下:
sys.io_errorbiz.order_not_found
才适合作为跨边界约定。
6. UnifiedReason 的角色
UnifiedReason 是内置的通用错误分类,已经实现:
DomainReasonErrorCodeErrorIdentityProvider
这意味着它可以直接:
- 作为
StructError<UnifiedReason>的 reason - 作为
#[orion_error(transparent)]的底层 reason - 进入稳定身份和协议投影路径
例如:
UnifiedReason::system_error()->sys.io_errorUnifiedReason::network_error()->sys.network_errorUnifiedReason::core_conf()->conf.core_invalidUnifiedReason::logic_error()->logic.internal_invariant_broken
7. 什么时候必须提供稳定身份
下面这些情况不要只停在 DomainReason:
- 要使用
identity_snapshot() - 要用
assert_err_identity(...) - 要输出 HTTP / CLI / log / RPC 响应
- 要做统一 exposure 决策
- 要把错误接入指标、聚合、告警
- 要形成跨 crate 或跨服务的错误契约
这时 reason 应该实现 ErrorIdentityProvider,而最省事的做法通常就是 derive OrionError。
8. 推荐模式
推荐让领域 reason 只有少量真正稳定的业务语义,并保留一个通用兜底分支:
#![allow(unused)]
fn main() {
use derive_more::From;
use orion_error::{OrionError, UnifiedReason};
#[derive(Debug, Clone, PartialEq, From, OrionError)]
enum OrderReason {
#[orion_error(identity = "biz.order_not_found")]
OrderNotFound,
#[orion_error(identity = "biz.insufficient_funds")]
InsufficientFunds,
#[orion_error(transparent)]
General(UnifiedReason),
}
}
这样做的好处是:
- 业务错误有稳定领域语义
- 通用系统错误不需要重复造轮子
- HTTP / RPC / log 等投影可以直接复用
UnifiedReason的稳定身份
9. 设计建议
设计 stable code 时,建议遵守:
- 不把动态值写进 code
- 不把实现细节写进 code
- code 尽量表达语义,不表达调用点
- category 保持粗粒度
推荐:
biz.invalid_requestbiz.order_not_foundconf.feature_invalidsys.io_error
不推荐:
read_config_failed_for_user_42primary_db_timeout_us_east_1error_1
协议契约
更新时间:2026-04-23
本文档描述 orion-error 当前已经落地的协议层设计。
这里说的“协议层”不是新的 runtime 传播模型,而是对外稳定消费接口。
1. 三层结构
当前协议层由三层组成:
- 稳定身份:
ErrorIdentity - exposure 决策:
ExposureDecision - 出口投影:HTTP / CLI / log / RPC / user debug
推荐把它理解为:
StructError<R>负责运行时传播ErrorIdentity负责稳定识别DiagnosticReport负责人类诊断ErrorProtocolSnapshot负责把 identity + decision + report 组装成统一消费输入
2. 稳定身份
稳定身份结构是 ErrorIdentity。
字段:
codecategoryreasondetailpositionpath
语义:
code:稳定机器主键category:稳定分类reason:稳定的人类摘要detail:可变补充说明,不是主键path:稳定导出的路径投影- 当前 runtime 主语义仍应优先理解为
action/locator/ path segments
入口:
StructError::identity_snapshot()assert_err_code(...)assert_err_category(...)assert_err_identity(...)
注意:当前 assert_err_code(...) 断言的是 stable code 字符串,不是数值 error_code()。
3. Exposure
exposure 决策结构是 protocol::ExposureDecision。
字段:
http_statusvisibilitydefault_hintsretryable
默认 exposure 策略实现是 protocol::DefaultExposurePolicy。
当前默认规则:
Biz -> 400 + PublicConf / Logic / Sys -> 500 + Internalsys.network_error/sys.timeout->retryable = true- 其他 stable code 默认
retryable = false
说明:
- 当前文档中的运行时主路径仍然是
doing(...) - top-level
want已从 identity / snapshot / protocol 投影中移除 - 兼容残留主要收在 context frame 的
target
主要入口:
ExposurePolicy::decide(...)StructError::exposure(...)StructError::into_exposure(...)- 完整 projection 数据以
StructError::exposure(...)为主路径
4. ErrorProtocolSnapshot
ErrorProtocolSnapshot 是当前统一协议输入。
结构:
identitydecision- 内嵌诊断 report,可通过
report()只读访问
入口:
StructError::exposure(...)StructError::into_exposure(...)
适用场景:
- 测试快照
- 网关二次投影
- 协议统一出口
- 用户调试摘要
5. HTTP Projection
JSON 字段:
statuscodecategorymessagevisibilityhints
规则:
Public时,message优先使用detailInternal时,message使用稳定reason
入口(需要 serde_json feature):
ErrorProtocolSnapshot::to_http_error_json()
6. CLI Projection
JSON 字段:
codecategorysummarydetailvisibilityhints
规则:
summary使用 compact renderdetail使用 verbose render
入口(需要 serde_json feature):
ErrorProtocolSnapshot::to_cli_error_json()
7. Log Projection
JSON 字段:
codecategoryreasondetailoperationpathvisibilityhintsroot_metadatacontextsource_frames
规则:
- 保留完整
context - 保留
root_metadata - 保留
source_frames
入口(需要 serde_json feature):
ErrorProtocolSnapshot::to_log_error_json()
8. RPC Projection
JSON 字段:
statuscodecategoryreasondetailvisibilityhintsretryable
规则:
detail只在Public可见时保留retryable完全来自 exposure decision
入口(需要 serde_json feature):
ErrorProtocolSnapshot::to_rpc_error_json()
9. User Debug Summary
render_user_debug(...) 是给人看的调试摘要,不是机器协议。
入口:
ErrorProtocolSnapshot::render_user_debug()ErrorProtocolSnapshot::render_user_debug_redacted(...)
它的定位是:
- 本地调试
- 示例输出
- 人工排障
它不是:
- public HTTP message
- 稳定 JSON schema
10. DiagnosticReport
DiagnosticReport 是 report 层对象。
它不依赖 ErrorIdentityProvider,因此更适合:
- 文本渲染
- redaction
- 人类诊断
常用入口:
StructError::report()StructError::into_report()StructError::report()/into_report()
如果启用了 serde_json feature,还可以使用:
- 协议投影应改走
StructError::exposure(...) - 然后使用
ErrorProtocolSnapshot::to_*_json()
11. 建议的消费路径
推荐顺序:
- 运行时传播用
StructError<R> - 要稳定识别时取
identity_snapshot() - 要统一出口规则时取
exposure(...) - 要协议出口时使用 projection API
- 要人类摘要时使用
render_user_debug(...)
不建议:
- 直接把
Display文本当协议主键 - 直接把 CLI 文本当机器协议
- 用
detail全文本做唯一稳定断言
Report / Exposure 边界
本文档描述 DiagnosticReport 与 ErrorProtocolSnapshot 之间的职责边界。
对象分工
| 对象 | 职责 |
|---|---|
StructError<R> | 运行时传播、source 链、上下文挂载 |
DiagnosticReport | 人类诊断视图、redaction、文本渲染 |
ErrorProtocolSnapshot | identity + exposure decision + report、协议 JSON projection |
推荐主路径
人类诊断:
#![allow(unused)]
fn main() {
let report = err.report();
let text = report.render();
}
协议/投影:
#![allow(unused)]
fn main() {
let proto = err.exposure(&policy);
proto.to_http_error_json()?;
proto.to_rpc_error_json()?;
}
原则
DiagnosticReport保持诊断对象定位ErrorProtocolSnapshot是唯一的 exposure/projection 闭包对象- 要文本诊断走
report(),要 JSON projection 走exposure(...)
日志说明
orion-error 的日志能力围绕 OperationContext 和 OperationScope 展开。
1. Feature
[dependencies]
orion-error = { version = "0.8.0", features = ["log"] }
# 或
orion-error = { version = "0.8.0", features = ["tracing"] }
默认 feature 已包含 log。
行为规则:
- 只启用
log:使用log宏输出 - 启用
tracing:优先走tracing - 同时启用:走
tracing
2. 基本用法
#![allow(unused)]
fn main() {
use orion_error::OperationContext;
let ctx = OperationContext::doing("order_processing")
.with_field("order_id", "123")
.with_field("amount", "100.0")
.with_meta("component.name", "order_service");
ctx.info("start");
ctx.debug("payload prepared");
ctx.warn("slow upstream");
ctx.error("final failure");
ctx.trace("verbose trace");
}
也可以使用别名:
log_infolog_debuglog_warnlog_errorlog_trace
3. 自动结果日志
#![allow(unused)]
fn main() {
use orion_error::OperationContext;
let mut ctx = OperationContext::doing("sync_user")
.with_auto_log()
.with_field("user_id", "42");
do_sync()?;
ctx.mark_suc();
}
默认结果是失败。
如果启用了 with_auto_log(),但离开作用域前没有调用:
mark_suc()mark_cancel()
那么 Drop 时会输出失败日志。
4. OperationScope
OperationScope 是面向一个局部作用域的 guard。
#![allow(unused)]
fn main() {
use orion_error::OperationContext;
let mut ctx = OperationContext::doing("sync_user").with_auto_log();
{
let mut scope = ctx.scope();
scope.with_field("user_id", "42");
validate()?;
scope.mark_success();
}
}
方法:
scope():默认失败,只有显式mark_success()才会成功scoped_success():创建后默认成功,除非后续显式mark_failure()或cancel()mark_success():标记成功mark_failure():恢复为失败cancel():标记取消
5. scoped_success() 的使用边界
scoped_success() 适合这种场景:
- 作用域里的逻辑已经自行处理完失败分支
- 失败时会明确调用
mark_failure() - 或者这段逻辑本身不会通过
?提前返回
例如:
#![allow(unused)]
fn main() {
let mut ctx = OperationContext::doing("process_order").with_auto_log();
{
let mut scope = ctx.scoped_success();
let ok = validate_order();
if !ok {
scope.mark_failure();
}
}
}
不推荐这样写:
let mut scope = ctx.scoped_success();
validate()?;
因为当前实现里 scoped_success() 一创建就默认成功,如果 ? 提前返回,Drop 仍会把该作用域标记为成功。
对可能早退的 fallible 流程,优先使用:
#![allow(unused)]
fn main() {
let mut scope = ctx.scope();
validate()?;
scope.mark_success();
}
6. op_context! 宏
#![allow(unused)]
fn main() {
use orion_error::op_context;
let ctx = op_context!("load_config").with_auto_log().with_field("path", "config.toml");
}
这个宏会在调用点展开 module_path!(),让自动结果日志带上更准确的模块路径。
7. 推荐实践
- 用
doing(...)命名操作 - 用
with_field(...)/with_meta(...)做链式构建 record_field(...)/record_meta(...)只在已有可变引用时使用- 用
with_auto_log()只包裹真正需要结果日志的作用域 - 对可能
?提前返回的逻辑,优先scope() + mark_success() - 只有在失败路径已被显式处理时,再使用
scoped_success()
orion-error 与 Rust 错误生态方案对比
对比范围:anyhow / thiserror / color-eyre / orion-error
1. 定位总览
| 维度 | anyhow | thiserror | color-eyre | orion-error |
|---|---|---|---|---|
| 定位 | 快速错误处理 | 标准错误类型 derive | 诊断式错误报告 | 结构化错误治理框架 |
| 目标用户 | 应用开发者(快速原型) | 库作者 | 应用开发者(诊断) | 大型多团队工程 |
| 问题域 | 减少错误处理样板代码 | 减少 Error impl 样板代码 | 改善错误诊断输出 | 统一错误建模 → 运行时传播 → 边界协议投影 |
| 抽象层级 | 类型擦除 | 类型安全 enum | 类型擦除 + 诊断 | 泛型结构化载体 |
2. 核心能力对比
错误定义
| 能力 | anyhow | thiserror | color-eyre | orion-error |
|---|---|---|---|---|
| 自定义错误类型 | 不直接支持 | #[derive(Error)] | 不直接支持 | #[derive(OrionError)] |
| 泛型错误类型 | Box<dyn Error> | 用户定义 enum | Box<dyn Error> | StructError<T: DomainReason> |
| 稳定 Identity | 无 | 无 | 无 | stable_code() + ErrorCategory |
| 数值 ErrorCode | 无 | #[error(...)] 间接 | 无 | 内建 error_code() |
Display / source | 自动 | 自动 | 自动 | 自动(OrionError derive) |
运行时传播
| 能力 | anyhow | thiserror | color-eyre | orion-error |
|---|---|---|---|---|
| 上下文附着 | .context(...) / .with_context(...) | 无 | .sections() / .note() / .with_section() | OperationContext(doing/at/path + KV + metadata) |
| Context 路径 | 单层 context 链 | 无 | 单层 | 多层嵌套 path 规整:target_path segments |
| 自定义元数据 | 无(仅消息) | 无 | Section trait | ErrorMetadata(typed KV,不进入 Display) |
| Source 链追踪 | 标准链 | 标准链 | 标准链 + SpanTrace | 双通道(Std/Struct)+ SourceFrame 丰富元数据 |
| 跨类型转换 | anyhow!() 宏 | #[from] | eyre!() 宏 | source_err(...) / conv_err() |
边界输出
| 能力 | anyhow | thiserror | color-eyre | orion-error |
|---|---|---|---|---|
| Human 诊断 | .display_chain() | 无 | {} 彩色输出 | report().render() + RedactPolicy |
| 协议 JSON (HTTP/RPC) | 无 | 无 | 无 | exposure() → to_http_error_json() / to_rpc_error_json() / to_cli_error_json() / to_log_error_json() |
| 稳定快照 | 无 | 无 | 无 | StableErrorSnapshot + schema_version |
| 暴露策略 | 无 | 无 | 无 | ExposurePolicy(status/visibility/hints/retryable + 按 stable_code 控制) |
| 脱敏/Redaction | 无 | 无 | 支持(有限) | RedactPolicy trait(贯穿 report/projection/identity) |
std::error::Error 生态
| 能力 | anyhow | thiserror | color-eyre | orion-error |
|---|---|---|---|---|
实现 StdError | 是 | 是 | 是 | 显式 interop(as_std() / into_std() / into_dyn_std()) |
dyn Error 兼容 | 天然 | 天然 | 天然 | 有损转换(OwnedDynStdStructError) |
| 与第三方错误互操作 | .context() / anyhow!() | #[from] | .sections() / eyre!() | source_err(...) / raw_source() |
3. 与 anyhow 对比
anyhow 的定位
anyhow 是 快速错误处理 工具。它的核心抽象是类型擦除:把一切错误抹平为 Box<dyn Error>,让调用方可以跳过繁琐的类型定义。
orion-error 的立足点
- anyhow 的目的地是“快速抹平然后继续“;orion-error 的目的地是“带着结构化身份和上下文走到边界再输出“
- orion-error 不愿意擦除类型——
StructError<T>保留了T: DomainReason的静态信息 - orion-error 的 context 不是单层 String,而是多层
OperationContext加结构化 path source_err(...)负责把上游错误接入当前层语义并保留 source 链;conv_err()只做 reason 收敛,不新增语义边界
各有所长的场景
| 场景 | 推荐方案 |
|---|---|
| 快速脚本、CLI 原型 | anyhow |
| 每一层都需要精确错误身份 | orion-error |
| 对外协议需要统一 error JSON | orion-error |
| 只需要“知道出错了“ | anyhow |
| 需要控制什么信息暴露给客户端 | orion-error |
4. 与 thiserror 对比
(参见 thiserror-comparison.md,这里只做摘要。)
- thiserror 是 标准错误类型 derive 工具;orion-error 是 治理框架
- thiserror 覆盖
Display/source/From生成;orion-error 覆盖结构化身份、上下文、快照、协议投影 - 两者不互斥:thiserror 类型可以作为 source 进入
StructError,边界外的标准错误继续用 thiserror
5. 与 color-eyre 对比
color-eyre 的定位
color-eyre 是 诊断体验增强 工具。它在 anyhow 基础上增加了:
- 彩色格式化的错误报告
- 自定义 Section 情感(
sections,notes,warnings) SpanTrace/Backtrace集成"Report Handler"可插拔模式
orion-error 的立足点
- color-eyre 仍然是 错误展示层 优化,而 orion-error 覆盖了从定义到边界输出的全链路
- color-eyre 没有 stable identity,没有 protocol projection,没有 ExposurePolicy
- color-eyre 的 Section 机制是自由格式(any type implements
Section),orion-error 的 context 有固定的 doing/at/path 语义 - color-eyre 的 Report Handler 可以自定义输出,但仍然是人类可读格式;orion-error 面向多协议输出(human + JSON + 稳定快照)
各有所长的场景
| 场景 | 推荐方案 |
|---|---|
| 终端应用需要漂亮错误输出 | color-eyre |
| 后端服务需要统一协议错误响应 | orion-error |
| 需要彩色 backtrace 和 span trace | color-eyre |
| 需要按错误身份做 Exposure 控制 | orion-error |
| 需要快照持久化 / 稳定导出 | orion-error |
6. 综合决策树
你的项目是多层服务/多团队工程吗?
├── 否 → 你只需要:
│ ├── 定义少量本地错误类型 → thiserror
│ ├── 快速处理错误 → anyhow
│ └── 终端展示要好 → color-eyre
└── 是 → 评估额外需求:
├── 错误身份需要稳定(协议/监控依赖) → orion-error
├── 需要统一错误 JSON 给 HTTP/RPC/CLI → orion-error
├── 需要脱敏/红action 策略 → orion-error
├── 需要快照持久化 → orion-error
└── 以上都不需要 → anyhow + thiserror 组合够用
7. 共存策略
orion-error 设计上不与生态对立。推荐的分工:
| 层 | 推荐方案 |
|---|---|
| 边界外(第三方库、FFI) | thiserror / 标准 Error trait |
| 进入结构化体系 | orion-error source_err(...) |
| 业务层传播 | orion-error StructError<R> |
| 跨层(repo → service → handler) | orion-error conv_err() |
| 边界输出 | orion-error exposure() |
| 快速原型 / 胶水代码 | anyhow(orion-error 提供了 feature anyhow 支持) |
| 终端诊断展示 | orion-error report().render() 或 color-eyre(非冲突) |
8. 推荐与不推荐
推荐 orion-error 的场景
- 多层架构的 Rust 后端服务(repo → service → handler → protocol)
- 对外提供 HTTP/RPC/gRPC 接口
- 微服务架构需要稳定错误身份和监控分类(
ErrorIdentity.code为主,ErrorCode为兼容数值码) - 多团队协作,需要统一工程规范
- 需要持久化/序列化错误快照
不推荐 orion-error 的场景
- 单文件脚本或 CLI 工具(anyhow 更轻量)
- 底层库需要纯
std::error::Error接口暴露(thiserror 更适合) - 项目只有一两层,不需要结构化上下文追踪
与 thiserror 的关系
orion-error 和 thiserror 不是互斥关系,但定位不同。
定位差异
thiserror:定义标准 Rust error 类型,服务于 std::error::Error 生态。
orion-error:定义运行时结构化错误载体,管理上下文、source frame、快照、协议投影。
能力对比
| 能力 | thiserror | orion-error |
|---|---|---|
| 定义标准错误类型 | 强 | 不是主要目标 |
| 领域 reason derive | 需要额外补稳定身份 | OrionError 是推荐入口 |
| 运行时结构化上下文 | 无 | 有 |
| source frame 追踪 | 无 | 有 |
| stable code / category | 无 | 有 |
| snapshot / report / projection | 无 | 有 |
什么场景保留 thiserror
- 对外公开标准
std::error::Error类型 - 外部库 API 要求标准 error 类型
Wukong 错误治理模型:稳定契约、可靠诊断与适配输出
这篇文章讨论一个问题:工业级系统如何把千变万化的错误纳入可治理、可诊断、可演进的结构。
如果只想快速把握主线,可以先读这四段:
- 核心矛盾:为什么错误治理的本质是”收敛 vs. 诊断”。
- 我们的方案:Wukong 错误治理模型:模型如何用稳定契约、可靠诊断和适配输出治理错误。
- 基于 Rust 的错误治理方案:如何用
orion-error落地。 - 工业级应用验证:WarpParse:高吞吐 ETL 场景如何验证这套方法。
本文分三层结构,可按需阅读:
- 方法论层(错误处理是原型与工业级的分水岭 → 治理等级):核心矛盾、Wukong 模型、五项原则、三种传播模式、治理成熟度。
- 工程落地层(基于 Rust 的错误治理方案):
orion-error如何把方法论落实到 Rust 代码,含设计规则和测试规范。 - 工业验证层(工业级应用验证:WarpParse → 面向 AI 的工程化复用):WarpParse 高吞吐 ETL 场景验证,以及如何沉淀为 AI 可复用的 engineering skills。
- 附录(语言机制与生态采纳):Java / TypeScript / Go / C++ / Swift / C# 的落地对比,不读不影响理解主线。
错误处理是原型与工业级的分水岭
原型只需证明正确路径可以跑通;工业级应用还须在非理想条件下可运行、可诊断、可恢复、可演进。
系统不会长期运行在理想条件下。输入变化,依赖退化,网络抖动,配置漂移,数据积累脏状态,业务规则迭代。处理路径随用户、环境、状态和策略动态分叉。正确路径不是系统运行的全部;失败、降级、重试、回滚、补偿和人工介入同样是生命周期的一部分。
因此,错误不是“正常逻辑之外的意外文本“,而是系统在非理想条件下继续运行、恢复状态、决定对外响应、支持诊断时必须传递的信息。
很多项目在早期把错误处理当作“每个函数自己的事“。每个函数决定如何表达失败,然后这个决策在下一个函数、下一个模块、下一个边界被重新做一次。
这种模式在小型项目中可以工作——调用链短、边界少、参与者对上下文有共同记忆。但当错误信息没有统一形态,且开始跨越团队、子系统、服务边界、协议边界或长期兼容边界时,失败路径就会变得不可治理:
- 同一种失败,A 模块返回字符串,B 模块返回 enum,C 模块直接 panic
- 边界输出时,每一层都在重新拼 JSON,结构却不一致
- 排障时,日志中有散落的消息,却没有一条完整的错误路径
- 重构时,不敢动错误类型——因为不知道哪些上层依赖了错误字符串内容
这些现象不一定来自某个函数“写得不好“。它们也可能来自缺乏统一工具、团队规范缺失、历史演进、人员流动或边界职责不清。关键在于:一旦错误需要在多个边界之间传播、被多个角色消费、并长期保持兼容,错误就不再只是局部控制流。
错误治理定义了失败发生后系统如何保留信息、跨层传递、对外暴露、支撑诊断和演进。它不是业务逻辑的装饰,而是业务逻辑失败时的信息架构,也是系统对抗腐化与脆弱的骨架。
行业一直在探索错误处理
错误处理的重要性已得到专业工程师和工业界的共同认可。真正困难的不是“要不要处理错误“,而是如何有效处理:既不能让它吞没业务代码,也不能让失败路径退化成不可治理的字符串;既要让调用方做稳定决策,又要让排障方保留足够细节。
不同语言对错误处理的设计,正说明这个问题长期没有唯一答案。
- C 主要依赖返回码、
errno和约定。直接、低成本,但错误信息容易分散,调用方也容易漏检。 - Java 把异常机制作为主路径,区分 checked exception 与 unchecked exception。它强化了错误传播能力,但也带来异常层次膨胀、边界语义不清和过度捕获的问题。
- Go 强调显式返回
error,让失败路径可见。但如果缺少团队约束,错误很容易变成层层包装的字符串。 - Rust 通过
Result<T, E>、?、枚举和类型系统把错误纳入普通控制流,让错误路径显式可组合。但分类、上下文、边界暴露和诊断策略仍需工程层面设计。
这些设计各有取舍。语言机制能降低错误处理成本,却不能替代错误治理本身。只能说,在错误处理这件事上,各种语言都还在探索基础能力:异常、返回值、Result、错误码,各有取舍,也都没有彻底解决问题。可从系统构建者的角度看,我们真正关心的并不是语法层面怎么抛、怎么传,而是失败信息在系统中如何被分类、保留、转换、暴露和观测。
对研发团队而言,错误处理横跨类型设计、调用链传播、日志与观测、协议输出、用户体验、运维策略和长期兼容。任一环节各自为政,最终都会在排障、重构和边界协作时暴露成本。
因此,错误处理不能只依赖个人经验和局部习惯,而需要一套可讨论、可执行、可演进的方法论。
到今天为止,业界并没有形成跨语言、跨框架、跨业务形态的统一错误治理方案。但一些优秀项目已经在不同方向给出了可参考实践:
- 稳定错误码
- 结构化诊断
- 集中边界策略
- 状态化错误呈现
- 可观测错误信号
- 面向用户的修复提示
这些实践说明,错误治理不是单一 API 问题,而是一组围绕失败信息展开的工程约束。
来自优秀项目的证据
| 项目 | 做法 | 启发 |
|---|---|---|
| gRPC | 跨语言 RPC 失败收敛为标准状态码 | 稳定分类让调用方可重试、降级、告警和映射用户响应 |
| PostgreSQL | 使用稳定 SQLSTATE 错误码,不依赖错误文本 | 机器契约和人类文案应该分离 |
| Kubernetes | 就绪状态、失败原因和 condition 写入 status | 错误可以是可查询、可自动化处理的系统状态 |
| Terraform | diagnostics 含 severity、summary、detail、attribute path | 错误应指出位置、原因和修复方向 |
| rustc | 错误码、源码位置、label、note、help 构成诊断体验 | 诊断信息本身是产品体验 |
| Envoy | access log response flags 表达稳定失败原因 | 边界层错误应能被聚合、搜索、告警和自动分析 |
这些项目形态不同,方向一致:优秀的错误处理都把失败路径设计成稳定的信息系统——既有机器可判断的分类,也有人能理解的诊断;既在内部保留细节,也在边界按策略暴露;既服务当前请求,也服务后续排障、监控和演进。
核心矛盾
任何错误治理方案都要处理一对根本矛盾:
收敛 vs. 诊断
- 调用方需要稳定的、有限的分类,否则无法做出治理决策(重试、降级、告警、返回给用户)
- 排障方需要完整的、保留细节的信息,否则无法定位根因
这两类需求都合理,但天然拉向不同方向。
如果错误向调用方暴露过多技术细节,上层就会依赖数据库、网络库、文件系统、第三方 SDK 的具体失败形态,系统边界被技术实现穿透。重构底层实现时,错误契约被迫变化。
如果错误只保留上层业务分类,排障时又会失去关键路径:原始失败是什么、发生在哪个组件、经过了哪些层、每层追加了什么上下文、最终为何被映射成这个对外响应。
因此,错误治理的主要矛盾不是“要不要包装错误“,而是:如何同时让错误在治理层面收敛,在诊断层面保真。
不充分的解决方案
| 策略 | 对调用方 | 对排障方 |
|---|---|---|
| 只抛技术异常 | 无法治理 | 信息完整 |
| 只抛业务错误 | 可以治理 | 丢失根因 |
| 纯字符串链式包装 | 无法治理 | 可读但不可结构化查询 |
| 保留类型信息的链式包装 | 可做局部治理 | 保留原因链,但分类与边界策略仍需额外约束 |
| 吞掉错误 | 干净 | 丢失所有信息 |
纯字符串链只是把错误文案一层层拼起来;保留类型信息的链式包装(cause chain、typed wrapping、errors.Is/errors.As)可支持一定程度的结构化查询,但它只解决“原因如何保留和查询“,不自动解决稳定错误标识、分类边界、暴露策略和治理动作映射。
这些方案如果只依赖单一形态同时满足两类需求,结果往往是牺牲其中一边:要么调用方信息太散无法自动化治理,要么排障方信息太少只能翻日志和复现。
接下来的问题是:现有语言机制和常见实践,为什么还没有把这件事真正解决?
砖块不等于建筑
一个常见质疑:Java 的异常 + 错误码、Go 的 sentinel error + wrapping、Rust 的 enum + cause chain,不是已经覆盖了 Wukong 错误治理模型要解决的大部分问题吗?
这些机制都是砖块,但砖块不等于建筑。问题出在三点。
错误标识不稳定。 Java 生态中异常类型承担分发角色:catch (OrderNotFoundException e)。但异常类型受继承层次控制,重构时会变。错误码是异常的附赠字段,调用方先 instanceof 再取 getErrorCode()——错误码不是路由主键。Wukong 错误治理模型的主张是:把错误标识提升为契约通道的第一公民,边界策略基于错误标识路由,与继承层次解耦。不管用 enum、错误码字符串还是 tagged union 表达,错误标识本身必须是稳定的、可文档化的、被测试约束的契约。
分类空间天然膨胀。 异常机制鼓励”一种失败一个类”:SubmitDependencyUnavailableException、InvalidStateException……类数量跟随业务失败模式无限制增长,没有机制强制收敛到有限分类。如果异常类型同时承担分类职责,分类就不可能稳定——每新增一个 exception class,所有依赖异常层次做路由的边界都可能受影响。Wukong 错误治理模型把分类空间(R)限制为有限枚举,新增变体是有意为之的兼容演进,不是随意加类。
契约和诊断共用同一通道,互相牵制。 cause chain、structured wrapping、errors.Is/errors.As 解决的是诊断保留——根因如何保留和查询。它们不解决:该触发重试还是降级?该映射到哪个 HTTP 状态码?该暴露给用户还是只记日志?这些治理动作若由异常类型、字段或局部判断来做,就会散落在每个 handler、每个 catch 块、每个 errors.Is 调用中。Wukong 错误治理模型把契约信息和诊断信息分离成不同通道,治理动作由集中策略而非局部代码决定。
综上,关键区别不在于你用什么语言机制,而在于你的错误架构有没有这四根承重墙:稳定错误标识(不受类型重构影响)、有限分类空间(受兼容演进规则约束)、诊断保留(跨层不丢失)、集中边界策略(不在 handler 中重复决策)。失去这些结构,任何语言的错误处理都会长成不可治理的灌木丛——哪怕砖块是顶好的。
我们的方案:Wukong 错误治理模型
本文把这套方法称为 Wukong 错误治理模型。它要”降”的不是神话里的妖怪,而是工业级系统里千变万化的错误:用稳定契约让错误现形,用可靠诊断保留根因和上下文,用适配输出为不同接收端提供稳定、合适的信息,并通过生产观测持续演进。
之所以叫 Wukong,是借用悟空在西行路上一路降妖除魔的意象。放到软件工程里,我们要降伏、封印的不是妖魔,而是各种错误:让它们有名有类、可查可控,不再以散乱字符串、隐式包装和边界泄露的形式四处作乱。
核心方法论:把稳定契约和可靠诊断分离到两个维度,再按接收端生成适配输出。
错误内部模型 = 契约通道 + 诊断通道
适配输出 = 对内部模型按策略生成的不同输出视图
契约通道由稳定错误标识、稳定分类和策略语义组成,服务治理决策(重试、降级、告警、HTTP/RPC/CLI 映射、用户提示、SLA 统计),应有限、稳定、可文档化、可测试。
诊断通道由诊断链、上下文和细节组成,服务问题定位(底层原因、经过的层、当前操作、关键字段、组件、环境),可以更丰富、更动态,但不应成为外部调用方的稳定契约。
契约通道和诊断通道描述的是错误在系统内部携带的信息结构;HTTP response、RPC error、CLI output、log record、metric label 和 debug report 则是到达不同边界后生成的适配输出。输出视图不应反过来污染错误内部模型。
| 通道 | 包含什么 | 服务谁 | 稳定性要求 |
|---|---|---|---|
| 契约通道 | 稳定错误标识、稳定分类、category、retryable、暴露等级 | 调用方、网关、监控、运维策略、协议客户端 | 高,应被文档化和测试约束 |
| 诊断通道 | 原因链、操作上下文、关键字段、动态细节、底层错误 | 开发者、SRE、排障工具、日志系统 | 可动态变化,但须详实可靠且可追溯 |
category 是稳定分类的固定治理维度,例如业务、系统、配置和逻辑错误,用于快速区分错误归属域,辅助告警路由、日志聚合和边界策略。retryable、暴露等级、HTTP 状态码、日志级别等不是错误文本本身,而是由稳定错误标识、分类和环境策略派生的边界决策。
这里有四个概念需要严格区分:
identity是长期稳定的错误标识,面向协议、监控、告警、文档和兼容性。reason是代码中的领域分类表达,通常是 enum、sealed class、tagged union 或异常子类。category是粗粒度治理维度,用于快速判断归属域和路由策略。policy是边界决策规则,由 identity、category 和环境策略共同决定输出视图。
简言之:identity 是契约主键,reason 是代码表达,category 是治理维度,policy 是边界动作。不要用错误消息替代 identity,不要用 category 替代 identity,也不要把 policy 决策散落到各个 handler 中。
reason 与 identity 的关系需要更严格地理解:reason 是进程内代码用来表达领域分类的类型,identity 是跨边界、跨版本的错误标识。一个 reason 变体通常提供一个稳定 identity;外部协议、监控和告警应依赖 identity.code,不应依赖 Rust enum 名、Java class 名或 Display 文案。跨语义域传播时,上层不应暴露下层 reason 类型,但可以把下层错误保留在诊断链中。
| 组成部分 | 含义 | 例子 |
|---|---|---|
| 稳定错误标识 | 机器可判读的错误主键,面向长期兼容 | order.not_found、system.timeout |
| 稳定分类 | 面向治理决策的有限类别 | 业务错误、配置错误、系统错误、超时、限流 |
| 治理属性 | 从稳定错误标识和分类派生的辅助决策字段 | category、retryable、暴露等级、HTTP 状态码 |
| 诊断链 | 跨层传播时保留的 cause/source 路径 | service failure -> repository failure -> database timeout |
| 上下文 | 当前操作的结构化环境,回答”在哪、对谁、执行什么” | operation、tenant、path、order_id、component |
| 细节 | 当前层对这次失败的具体解释 | read config failed、upstream returned 503 |
同一个错误通过不同视图同时服务两类需求,减少调用方和排障方互相牺牲。
契约通道应保持低基数、低动态性和长期稳定。它不应包含租户 ID、文件路径、SQL、HTTP body、第三方错误文本、用户输入或具体字段值;这些信息属于诊断通道,应进入 detail、context 或 source chain。契约通道越稳定,监控聚合、告警路由、协议兼容和自动化决策越可靠;诊断通道越详实可靠,排障和修复越有效。
不同信息的稳定性不同,设计和测试时不应一视同仁:
| 信息 | 稳定性 | 是否适合作为外部契约 |
|---|---|---|
| 错误标识码 | 最高 | 是 |
| category | 高 | 通常是 |
| reason 变体 | 中高 | 代码内契约 |
| retryable / visibility / HTTP 状态码 | 中 | 策略契约 |
| detail | 低 | 否 |
| context fields | 低到中 | 通常否 |
| source chain | 低 | 否 |
因此,测试应优先断言 identity 和策略结果,而不是断言 detail 文案;文档应承诺稳定错误标识和分类语义,而不是承诺具体诊断文本。
方案原则
Wukong 错误治理模型落地需要五项原则配合:统一载体承载结构,契约通道保持稳定,诊断通道详实可靠,边界集中输出,外部生态显式桥接。
本节代码只表达方法论形状,是语言无关的伪代码。
原则一:用统一载体承载契约与诊断信息
自有跨层错误传播路径应使用统一的结构模型。
反例:
#![allow(unused)]
fn main() {
// A 模块返回 io::Error
fn read_file() -> io::Result<Data>
// B 模块返回自定义 enum
fn validate() -> Result<Data, ValidationError>
// C 模块返回字符串
fn process() -> Result<Data, String>
}
每条错误路径的调用者都需学习一套新形状。组合两个不同函数的错误路径时,调用方既要判断分类,又要重新拼接诊断信息,还要决定边界输出格式。
正例:
read_file() -> Result<Data, StructuredError<ErrorClass>>
validate() -> Result<Data, StructuredError<ErrorClass>>
process() -> Result<Data, StructuredError<ErrorClass>>
载体模型统一,才能同时承载稳定分类和诊断信息。变化的是分类空间和上下文。不同层可拥有自己的分类空间,但跨层传播时应有清晰的收敛或边界转换规则。
统一载体不是要求第三方库、标准库、框架异常或协议错误全改成同一种类型;它要求团队控制的内部传播路径使用同一种结构模型,进入或离开外部生态时显式桥接。
原则二:让契约通道保持稳定
错误分类契约应按向后兼容规则演进。
错误分类体系是契约——调用方依赖它做治理决策。“稳定“不是指不能新增分类,而是已有分类的机器标识和语义不能随意变化。
错误标识是契约通道里的机器主键,通常表现为稳定字符串、错误码或协议字段(如 business.not_found、system.timeout)。调用方、网关、监控、告警和文档都应依赖这个标识,而非错误消息文本。
分类契约的兼容规则:
- 可以新增错误标识或分类,表达新的业务失败或系统失败。
- 不应删除已对外承诺的错误标识;若必须废弃,应保留兼容映射或经明确的版本迁移。
- 不应改变已有标识的语义(如把
business.not_found从“资源不存在“改成“无权限访问“)。 - 不应让同一标识在不同边界产生矛盾的治理动作(如一处可重试、另一处不可重试)。
- 可以调整错误文案、诊断细节、上下文字段和底层原因链,只要不破坏标识和分类语义。
| 应该稳定的 | 可以变化的 |
|---|---|
| 稳定错误标识 | 诊断细节 |
| 分类语义 | 错误信息文案 |
| category(业务/系统/配置) | 具体的技术细节 |
稳定分类的另一个好处:它是人和系统之间的共享接口。运维配置告警规则、网关配置状态码映射、API 文档描述错误响应——全部依赖稳定错误标识和分类语义,而非错误文本。枚举、异常类型、错误码或 tagged union 只是表达这个契约的具体方式。
分类粒度需要被约束。新增稳定错误标识通常需要满足至少一个条件:
- 调用方需要不同的治理动作,如重试、降级、停止重试或人工介入。
- 边界需要不同的协议状态、公开错误码、用户消息或修复提示。
- 监控、告警、SLA 或运营报表需要独立聚合。
- SRE、业务方或规则开发者需要把它作为独立失败类型跟踪。
- 语义长期稳定,不依赖当前数据库、SDK、网络库或实现细节。
以下情况通常不应新增稳定错误标识:
- 只是错误文案不同。
- 只是字段名、文件名、租户、路径、行列号或样本内容不同。
- 只是底层库错误类型不同,但治理动作相同。
- 只是为了让日志更详细。
这些动态差异应进入诊断通道。否则分类空间会不断膨胀,契约通道从稳定契约退化成另一种日志文本。
原则三:让诊断通道跨层保真
错误在内部传播时应追加信息,不应破坏已有诊断链。
反例:
repository() -> Result<Data, RepoError> {
// 数据库连接失败,返回 RepositoryConnectionFailed
}
service() -> Result<Data, ServiceError> {
data = repository()? // 丢弃了下层错误的具体信息
return data
}
正例:
repository() -> Result<Data, StructuredError<RepositoryClass>> {
// 数据库连接失败,保留原始数据库错误
}
service() -> Result<Data, StructuredError<ServiceClass>> {
data = repository()
.source_err(ServiceDependencyFailed, "load repository data failed")
return data
}
每层保留的信息形成完整错误链,排障时从最终错误追溯到原始根因。
这里有两种操作:
- 若当前层只把下层分类收敛到上层分类空间、不建立新语义边界,应保留原有诊断链,不制造新错误叙事。
- 若当前层要表达新的失败语义,应把下层错误作为原因保留,并追加当前层解释。
判断标准是语义域,不是函数层数。
若上下层属同一语义域,错误转换通常只是分类收敛。例如 database driver、query executor、repository helper 同属 data access 语义域;它们之间可将底层连接失败收敛为 RepositoryConnectionFailed,同时保留原始数据库错误和上下文,不必每层都追加业务叙事。
若错误跨越了语义域或架构责任边界,就应建立新语义边界。例如 data access 失败进入 order service 时,上层关心的不是“数据库连接失败“,而是“订单草稿加载失败“或“提交订单依赖不可用“。此时应追加 service 层语义,并把下层 data access 错误作为原因保留。
辅助判断问题:
- 当前层是否在向上层隐藏实现细节?
- 当前层是否拥有新的业务含义、用户意图或操作目标?
- 当前层是否会改变治理动作(如从底层 timeout 映射为业务依赖不可用)?
- 若未来替换下层实现,上层错误契约是否应保持不变?
若答案为“是“,这里通常是语义边界;若只是模块拆分、工具函数或同领域内的技术分层,只需分类收敛和诊断保留。边界输出时再按策略做脱敏和格式化。
原则四:在边界集中输出
边界暴露策略应集中定义,不在每个边界点重新决定。
结构化错误在内部传播时携带契约通道和诊断通道;到达边界时,边界层从契约通道取得稳定错误标识(error_identity),交给统一策略生成输出视图。
StructuredError<ErrorClass>
-> error_identity
-> exposure_policy
-> HTTP response / RPC error / CLI output / log record / metric label
反例:
// handler A
match err {
NotFound => HttpResponse(404, "not found"),
Timeout => HttpResponse(503, "try again"),
}
// handler B
match err {
NotFound => HttpResponse(404, "resource missing"),
Timeout => HttpResponse(504, "gateway timeout"),
}
两个 handler 对同一错误的输出不一致。
正例:
// 策略集中定义
policy.status(error_identity) {
match error_identity {
"business.not_found" => 404
"system.timeout" => 503
_ => 500
}
}
// 所有边界点使用同一策略
render_error_response(err, policy)
集中策略不只负责 HTTP 状态码,还应覆盖不同输出视图:
- 对外错误码和用户可见消息
- HTTP/RPC/CLI 格式映射
- 日志级别和结构化日志字段
- 是否触发告警或计入 SLA
- 是否建议调用方重试、降级或停止重试
- 诊断信息脱敏和暴露等级
- 指标标签和错误聚合维度
这些决策若散落在每个 handler、worker、controller 中,同一错误标识就可能在不同边界产生矛盾输出,破坏契约通道稳定性。
原则五:显式桥接外部生态
进入外部生态(日志系统、标准错误接口、第三方库)应是显式的。
反例:
// 调用者在不知情的情况下把错误降级为普通字符串
handle(error_as_text) // 擦除了结构化信息
正例:
// 显式选择进入外部生态
plain_error = err.to_plain_error()
log_record = err.to_log_record(redaction_policy)
显式桥接确保结构化信息的丢失、脱敏或降级是有意为之而非无意遗漏。每个桥接函数应有清楚的桥接契约:
- 目标消费者是谁:用户、协议客户端、日志系统、监控系统、第三方库,还是标准错误接口。
- 保留什么:稳定错误标识、分类、原因链摘要、操作上下文、关键字段、retryable、visibility。
- 丢弃什么:内部实现类型、敏感字段、过长底层错误、无法稳定解析的动态文本。
- 脱敏什么:token、密钥、用户隐私、租户隔离信息、内部拓扑、SQL 片段或请求载荷。
- 如何降级:当目标生态只能接收字符串或普通异常时,哪些字段压缩进文本,哪些彻底丢失。
不同桥接目标应有不同契约:写日志时保留错误标识、分类、上下文、关键字段和原因链摘要;对外响应时只暴露错误码、可公开消息和修复提示;进入标准错误接口时可能只保留文本和 source 链。桥接的重点不是“所有信息都带出去“,而是每次输出都可审计、可测试、可预期。
边界安全与跨进程输出
结构化错误在内部保留了更多信息,因此边界输出必须同时处理治理、安全和隐私。诊断通道可以丰富,但不代表所有信息都能离开当前信任域。
信任域应分层处理:
| 信任域 | 推荐传递内容 |
|---|---|
| 进程内 | 完整结构化错误、source chain、detail、context |
| 服务间 / 进程间 | 协议快照、identity、category、公开消息、retryable、correlation id |
| 用户边界 | 稳定错误码、可公开消息、必要修复提示、关联 ID |
| 观测系统 | 脱敏后的诊断摘要、关键 context、聚合标签 |
| 支持包 / 调试报告 | 权限控制下的更完整诊断信息,并受脱敏和生命周期约束 |
不同视图应有不同暴露等级:
- 用户响应:只包含稳定错误码、可公开消息、必要的修复提示和关联 ID。
- 协议客户端:包含机器可判断的 identity、category、retryable 等治理字段,但不暴露内部 source chain。
- 日志和 report:保留脱敏后的上下文、原因链摘要和关键诊断字段。
- 支持包或调试报告:可包含更完整诊断信息,但必须经过权限、脱敏和生命周期控制。
跨服务、跨进程或跨消息队列传播时,通常不应直接传递完整内部错误对象。更稳妥的方式是传递协议快照:稳定 identity、category、公开消息、retryable、correlation id 或 trace id;完整诊断链留在产生错误的服务内,通过日志、trace 和 report 关联查询。
这样可以避免两个问题:一是把内部实现细节泄露给外部调用方;二是让下游依赖上游的内部错误类型、source chain 或底层库形态。跨进程边界上的错误契约应是协议契约,不是进程内诊断结构的直接序列化。
错误传播的三种模式
以上五项原则定义了错误治理的静态结构:统一载体承载信息,契约通道保持稳定,诊断通道跨层保真,边界集中输出,外部生态显式桥接。下面三种传播模式描述的是错误的动态生命周期——一个错误如何首次进入结构化体系、如何跨语义域转换、如何最终在边界输出。原则回答“结构应该长什么样“,模式回答“运行时如何流转“;两者是同一套方法论的互补视角。
错误传播不是机械向上抛。一个工业级错误经历三种动作:首次进入、跨层转换、边界输出。
首次进入
原始错误(IO、解析、网络错误)首次进入结构化系统,需同时完成:
- 选择分类(业务 vs 系统 vs 配置)
- 给出当前层解释(detail)
- 保留原始错误作为底层原因
三个诊断概念的分工:source/cause 回答根因是什么,context 回答在哪、对谁、执行什么,detail 回答当前层如何理解这次失败。
跨层转换
上层将下层错误分类收敛到自己的分类空间:若只做分类重新映射,保留所有诊断信息;若要建立新语义边界,将下层错误作为原因包裹。取决于当前层是否是新语义边界。
语义边界不是架构层边界。跨函数、跨文件、跨 helper、跨 adapter,不必然意味着要建立新的错误叙事;跨越业务意图、用户操作、治理动作或实现隐藏边界时,才需要新的稳定错误标识。过度包装会让 source chain 充满重复的 “failed to process”;包装不足又会把底层技术细节暴露给上层契约。判断标准始终是语义责任,而不是调用栈深度。
跨层传播时可以用三个问题快速判断:
| 当前层动作 | 是否新增稳定 identity | 是否新增 source frame | 心智模型 |
|---|---|---|---|
| 只把下层分类收敛到上层分类 | 否,只做映射 | 否 | reason 收敛,例如 conv_err() |
| 表达新的业务或架构语义 | 是 | 是 | 建立新语义边界,例如 source_err(...) |
| 只补充当前操作路径或字段 | 否 | 否 | 追加上下文,例如 doing(...) / with_context(...) |
这张表的重点是避免两个极端:把每一层都包装成新的错误叙事,或者把跨语义域的底层技术失败直接暴露给上层。
边界输出
在系统边界(HTTP handler、RPC 端点、CLI 入口、日志写入点)输出错误:选择输出格式,应用暴露策略,输出。
一个完整传播示例
下面是一次“提交订单“失败经过三种模式的完整路径。
第一步,数据库错误首次进入结构化系统。repository 层选择 data access 语义下的稳定分类,保留数据库错误为 source,添加操作上下文。
repository.insert_order(order) -> Result<(), StructuredError<RepositoryClass>> {
db.insert(order)
.on_error(source_error) {
return StructuredError {
identity: "repository.connection_failed",
class: RepositoryConnectionFailed,
detail: "insert order failed",
context: {
operation: "insert_order",
order_id: order.id,
component: "order_repository"
},
source: source_error
}
}
}
第二步,service 层跨到业务语义域。不把数据库连接失败暴露给上层,而是表达业务失败:提交订单依赖不可用,同时保留下层 repository 错误为 cause。
service.submit_order(order) -> Result<(), StructuredError<ServiceClass>> {
repository.insert_order(order)
.on_error(repo_error) {
return StructuredError {
identity: "order.submit_dependency_unavailable",
class: SubmitDependencyUnavailable,
detail: "submit order failed",
context: {
operation: "submit_order",
order_id: order.id,
tenant: order.tenant
},
source: repo_error
}
}
}
第三步,HTTP handler 到达边界。不重新解释错误,交给集中策略生成输出。
handler.post_orders(req) -> HttpResponse {
result = service.submit_order(req.order)
if result is error {
err = result.error
identity = err.identity
log_record = policy.to_log_record(err)
metrics.record(policy.metric_labels(identity))
return HttpResponse {
status: policy.http_status(identity),
body: policy.public_body(identity),
retry_after: policy.retry_after(identity)
}
}
}
契约通道最终给边界的是 order.submit_dependency_unavailable,用于决定状态码、用户消息、重试建议和指标标签;诊断通道保留了 service detail、repository detail、上下文和原始数据库错误。调用方不需要知道数据库细节,排障方仍可追溯根因。
错误治理的生命周期
运行时传播只是错误治理的一部分。稳定分类进入生产后,还要经过观测和演进:
detect -> classify -> enrich -> propagate -> project -> observe -> review/evolve
- detect:在失败发生处捕获原始错误或业务失败。
- classify:选择当前语义域下的稳定错误标识和分类。
- enrich:追加 detail、context 和 source,不污染契约通道。
- propagate:跨层传播时保留诊断链,必要时建立新语义边界。
- output:在 HTTP/RPC/CLI/log/metric 等边界按策略生成输出视图。
- observe:通过日志、指标、trace、report 观察错误分布和治理效果。
- review/evolve:根据生产反馈合并、废弃或新增错误标识,调整策略和文档。
这一步很重要:错误分类不是一次性建模。某个错误标识如果长期承载多种治理动作,说明分类过粗;某批错误标识只有文案差异、治理动作相同,说明分类过细。L2 之后的错误治理,需要用生产观测反向校准分类契约。
治理等级
错误治理成熟度分四个等级:
L0:无治理
- 错误类型散乱:
std::io::Error/String/Box<dyn Error>/ 自定义 enum 混用 - 边界输出拼接字符串
- 排障依赖 grep 日志
L1:统一载体
- 自有跨层路径返回同一结构模型
- 具备基本原因链,但跨层时仍可能被丢弃
- 仍没有稳定分类契约,同一失败在不同模块归类不一致
- 这只是统一表达和传播,还不是治理
L2:稳定分类
- 分类契约稳定,有文档定义
- 边界输出使用统一策略
- 原因链跨层完整保留
- 测试断言错误标识,而非错误消息
L3:治理驱动
- 错误分类直接映射到治理动作(重试、降级、告警、SLA)
- 边界策略可配置,不同环境可不同
- 错误指标进入监控系统
- 新错误类型需 review 才能加入
大多数团队在 L0 和 L1 之间。L1 到 L2 是最容易被低估的一步:不是把返回类型换成统一载体就结束了,而是要让团队对“哪些失败共享同一错误标识“、“哪些分类代表可重试”、“哪些错误可对外暴露“形成共同语义。Java 的异常机制可以帮助项目达到 L1,但它不会自动提供稳定分类、统一边界策略或测试约束。
从 L1 到 L2 需要:
- 标准化分类契约,明确稳定错误标识、分类语义、category 和治理含义。
- 梳理存量错误,把散落的字符串、技术异常和临时 enum 迁移到稳定分类。
- 建立边界策略,统一 HTTP/RPC/CLI/log/metric 输出规则。
- 建立测试规范,断言错误标识和治理决策,而非断言错误消息。
- 建立评审习惯,新增错误时讨论语义归属,而非只讨论能否编译。
进入 L2 后,测试不应只检查“返回了错误“。更有价值的测试矩阵包括:
- identity 是否稳定,且不依赖错误消息文本。
- category、retryable、visibility、HTTP 状态码是否符合策略。
- source chain 是否保留了下层根因。
- context 是否包含定位问题所需的关键字段。
- exposure 是否正确脱敏,用户响应是否没有泄露内部细节。
- HTTP/RPC/CLI/log/metric 等输出视图对同一错误标识是否一致。
L1 到 L2 不是局部重构,而是团队协作模式的变化:错误分类从个人实现细节变成共享工程语言。
L3 意味着错误治理进入组织流程。新错误类型需要 review,因为每个新稳定错误标识都可能影响告警、重试、SLA、用户文案、协议兼容和运维看板。到了这个阶段,错误分类变更应像 API 变更一样被管理:有命名规范、兼容性规则、策略映射、测试覆盖,也有废弃和迁移路径。
不适用场景
- 小型项目、原型、脚本。 边界少、生命周期短、错误在局部处理时,没必要引入分层治理。
- 性能极端敏感的场景。 结构化错误路径有分配、原因链和上下文采集、序列化等成本;静态类型语言中泛型或模板还可能增加编译时间和代码体积。
- 错误不需要跨层传播。 若所有错误都在一层内处理完毕,收益接近于零。
阶段性小结
以上完成了通用方法论:错误治理为什么重要、核心矛盾是什么、Wukong 错误治理模型如何组织失败信息。接下来进入 Rust 落地。
基于 Rust 的错误治理方案
Rust 适合做结构化错误治理,但 Rust 不会自动完成治理。Result<T, E>、enum、? 和 trait 解决的是错误表达与传播的语法;稳定错误标识、语义边界、诊断链保留、边界输出和桥接契约,仍需工程层面设计。
orion-error 把 Wukong 错误治理模型落成 Rust 基础设施:
Result<T, StructError<R>>
R -> 契约通道的 reason / identity / category
StructError<R> -> 运行时载体,承载 detail / context / source chain
ExposurePolicy -> 边界输出策略
report / interop -> 诊断与外部生态桥接
R 是当前语义域的错误分类契约。不同 bounded context、架构层或业务域可拥有自己的 Reason 类型,跨域传播通过显式转换表达语义边界。
下图展示一个错误从底层失败进入结构化体系、跨语义域传播、最终在边界输出的过程。抓住三点:
- 内部传播使用统一载体
StructError<R>。 - 同一错误同时携带契约通道和诊断通道。
- 边界层只按策略生成输出视图,不重新解释错误。
flowchart TB
raw["原始失败<br/>IO / DB / Network / Parser"]
repo["Repository 层<br/>StructError<RepositoryReason>"]
service["Service 层<br/>StructError<OrderReason>"]
boundary["系统边界<br/>HTTP / RPC / CLI / Worker"]
raw -->|"首次进入<br/>source_err(reason, detail)"| repo
repo -->|"跨语义域<br/>source_err(new_reason, detail)"| service
service -->|"边界输出<br/>exposure(policy)"| boundary
subgraph governance["契约通道:稳定、有限、可测试"]
identity["identity<br/>order.submit_dependency_unavailable"]
category["category<br/>biz / sys / conf / logic"]
policy["policy<br/>status / retry / visibility / hints"]
end
subgraph diagnostic["诊断通道:保真、可追溯"]
detail["detail<br/>submit order failed"]
context["context<br/>operation / order_id / tenant / component"]
source["source chain<br/>service -> repository -> database"]
end
service -.携带.-> identity
service -.携带.-> category
service -.携带.-> detail
service -.携带.-> context
service -.携带.-> source
identity --> policy
category --> policy
policy --> boundary
boundary --> user["对外响应<br/>稳定错误码 + 可公开消息"]
boundary --> log["日志 / Report<br/>诊断摘要 + 脱敏上下文"]
boundary --> metric["指标 / 告警<br/>identity + category"]
图中的关键点是:错误在内部传播时不被压扁成字符串;边界层根据策略生成用户响应、日志/report 和指标/告警三个输出视图。
五项原则的实现映射
| 方法论原则 | Rust / orion-error 落地方式 | 作用 |
|---|---|---|
| 用统一载体承载契约与诊断信息 | 内部跨层传播统一使用 Result<T, StructError<R>> | 调用方面对同一种错误形状,分类空间由 R 参数化 |
| 让契约通道保持稳定 | 领域 reason 定义稳定 identity、category 和分类语义 | 调用方、监控、协议边界依赖错误标识,不依赖错误文案 |
| 让诊断通道跨层保真 | 使用 detail、context、source chain 保留底层原因与当前层解释 | 上层可以收敛分类,排障仍能追溯根因 |
| 在边界集中输出 | 通过 exposure policy 统一决定 HTTP/RPC/CLI/log/metric 输出 | 避免每个 handler 各自拼响应、各自决定脱敏 |
| 显式桥接外部生态 | report、redacted render、std error interop、protocol JSON 等路径显式转换 | 每次信息降级、脱敏、暴露都有清楚契约 |
设计规则一:按语义域定义 Reason
每个语义域定义自己的 reason 类型,而不是把全系统错误塞进一个巨大的全局 enum。
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, OrionError)]
enum RepositoryReason {
#[orion_error(identity = "repository.connection_failed")]
ConnectionFailed,
#[orion_error(identity = "repository.write_failed")]
WriteFailed,
#[orion_error(transparent)]
General(UnifiedReason),
}
#[derive(Debug, Clone, OrionError)]
enum OrderReason {
#[orion_error(identity = "order.submit_dependency_unavailable")]
SubmitDependencyUnavailable,
#[orion_error(identity = "order.invalid_state")]
InvalidState,
#[orion_error(transparent)]
General(UnifiedReason),
}
}
对应前文的稳定分类契约:repository.connection_failed 是 data access 语义,order.submit_dependency_unavailable 是业务语义。两者可由同一底层失败触发,但不应混成同一分类。
设计规则二:首次进入时建立结构化错误
普通 IO、数据库、网络、解析错误首次进入治理体系时,需同时完成:选择当前层分类、给出 detail、保留底层 source。
#![allow(unused)]
fn main() {
fn insert_order(order: &Order) -> Result<(), StructError<RepositoryReason>> {
let ctx = OperationContext::doing("insert_order")
.with_field("order_id", order.id.to_string())
.with_meta("component.name", "order_repository");
db_insert(order)
.source_err(RepositoryReason::ConnectionFailed, "insert order failed")
.map_err(|err| err.with_context(ctx))?;
Ok(())
}
}
不把底层错误转成字符串。底层错误是 source,"insert order failed" 是 repository 层 detail,order_id 和 component.name 是 context。
设计规则三:跨语义域时建立新边界
同一语义域内的分类收敛只做 reason 转换,不新增错误叙事。跨越到新业务语义域时,建立新语义边界,把下层结构化错误作为 source 保留。
#![allow(unused)]
fn main() {
fn submit_order(order: &Order) -> Result<(), StructError<OrderReason>> {
let ctx = OperationContext::doing("submit_order")
.with_field("order_id", order.id.to_string())
.with_field("tenant", order.tenant.to_string());
insert_order(order)
.source_err(
OrderReason::SubmitDependencyUnavailable,
"submit order failed",
)
.map_err(|err| err.with_context(ctx))?;
Ok(())
}
}
service 层不把 repository 的连接失败暴露给 handler,而是表达业务失败:提交订单依赖不可用。repository 错误仍在 source chain 中。
设计规则四:边界只做输出,不重新解释错误
HTTP handler、RPC 端点、CLI 入口和 worker 边界不应重新拼装错误语义,应把结构化错误交给集中策略统一生成响应、日志、指标和调试报告。
#![allow(unused)]
fn main() {
fn handle_submit(req: Request) -> HttpResponse {
match submit_order(&req.order) {
Ok(()) => HttpResponse::ok(),
Err(err) => {
let snapshot = err.exposure(&DefaultExposurePolicy);
log_error(err.report());
HttpResponse::from(snapshot)
}
}
}
}
边界有多个输出视图:对用户 redacted exposure,对开发者和 SRE 保留 report,对监控输出稳定 identity 和 category。
设计规则五:测试错误标识,而不是错误文案
进入 L2 后,测试约束稳定错误标识和治理决策,不约束错误消息。错误消息可优化、翻译、脱敏;错误标识和分类语义才是长期契约。
#![allow(unused)]
fn main() {
let err = submit_order(&order).unwrap_err();
assert_eq!(
err.identity_snapshot().code,
"order.submit_dependency_unavailable"
);
let exposed = err.exposure(&DefaultExposurePolicy);
assert_eq!(exposed.decision.http_status, 503);
}
这类测试倒逼团队维护稳定分类契约:新增错误要有标识,修改标识要考虑兼容,边界输出策略要有明确预期。
可运行完整示例参见 orion-error/examples/order_case.rs:解析层、用户层、存储层和订单服务层各自定义 reason,底层错误首次进入结构化体系,跨层传播通过 reason 收敛保留诊断链,最终在边界统一输出。这个示例主要展示 conv_err() 收敛路径;如果上层需要建立新的业务语义边界,应按本文规则使用 source_err(...) 把下层结构化错误保留为 source。
工业级应用验证:WarpParse
orion-error 是 Wukong 错误治理模型在 Rust 下的基础设施实现。基础设施还需要真实工业级系统验证:高吞吐、长链路、多角色、多边界、强观测要求的系统,才真正检验错误治理是否可用。
WarpParse 是 Orion 体系中面向高吞吐日志解析与 ETL 的核心引擎。根据 wp-examples/benchmark/report/report_linux.md 的 Linux 单机 benchmark,WarpParse 0.12.0 在 Nginx、AWS ELB、Firewall、APT Threat、Mixed Log 五类日志及 File -> BlackHole、TCP -> BlackHole、TCP -> File 三种拓扑下,对比 Vector-VRL 0.49.0 取得纯解析 1.56x-20.30x、解析+转换 1.34x-17.90x 的 EPS 倍数区间。
Benchmark 证明的是工业强度:高吞吐、多格式、多拓扑、解析与转换并存。它本身不证明错误治理质量。错误治理的价值,需从失败路径能否被定位、分类、输出和自动化处理来判断。
因此,WarpParse 对这套方法论的验证不在于吞吐数字本身,而在于复杂失败路径是否能被稳定表达:
- 规则错误能否定位到文件、行列和字段。
- 配置错误能否阻断发布,而不是触发系统故障告警。
- 数据质量错误能否聚合统计,而不污染系统错误。
- 运行时错误能否区分可重试、不可重试和需要人工介入。
- 用户视图、运维视图和调试视图是否来自同一个稳定错误标识。
在这类系统中,若规则语法错误只返回一段字符串:
unexpected token at line 12
规则开发者仍需打开规则文件、定位行列、猜测出错字段、判断是语法问题还是样本不匹配。系统也难以基于文本稳定区分“配置错误“、“数据质量问题“和“运行时系统错误”。
引入 Wukong 错误治理后,同一次失败被表达为结构化信息:
identity : rule.syntax
category : config
detail : unexpected token in extractor expression
context : {
rule_file : "rules/nginx.wpl",
line : 12,
column : 18,
field : "request_time",
expected_token : "identifier",
actual_token : ")"
}
policy : block rule activation, show repair hint, do not page SRE
这是方法论在 WarpParse 中被验证的关键:规则开发者拿到错误位置和修复线索;运行系统拿到稳定错误标识和治理策略;运维侧可把配置错误、数据错误、系统错误分开统计和告警。吞吐越高,失败路径越需要结构化能力,否则处理能力越强,错误扩散和排障成本也被同步放大。
WarpParse 的错误治理结构
WarpParse 的错误处理覆盖规则开发、规则验证、运行时解析、管线执行、边界输出和运维观测的完整链路。下图按三层阅读:失败来源、契约与诊断承载、输出视图。
flowchart TB
sample["样本日志<br/>Nginx / ELB / Firewall / APT / Mixed"]
rule["WPL 规则<br/>字段提取 / 类型转换 / 富化"]
check["规则验证<br/>syntax / sample / schema"]
engine["解析运行时<br/>高吞吐 parse / transform"]
pipeline["ETL 管线<br/>input -> parse -> transform -> output"]
boundary["系统边界<br/>CLI / API / worker / report"]
sample --> check
rule --> check
check -->|"规则可用"| engine
engine --> pipeline
pipeline --> boundary
subgraph failure["失败来源"]
syntax["规则语法错误"]
mismatch["样本不匹配"]
typeerr["类型转换失败"]
dirty["脏数据 / 异常字段"]
runtime["运行时 I/O / backpressure / resource"]
end
syntax --> check
mismatch --> check
typeerr --> engine
dirty --> engine
runtime --> pipeline
subgraph governance_wp["契约通道"]
wp_identity["稳定错误标识<br/>rule.syntax / parse.mismatch / transform.type / runtime.io"]
wp_category["category<br/>config / data / system"]
wp_policy["策略<br/>是否中断 / 是否跳过 / 是否告警 / 是否可重试"]
end
subgraph diagnostic_wp["诊断通道"]
wp_rule_ctx["规则上下文<br/>rule file / line / field / pattern"]
wp_sample_ctx["样本上下文<br/>sample id / input slice / expected field"]
wp_runtime_ctx["运行时上下文<br/>source / sink / batch / offset / component"]
wp_source["source chain<br/>parser -> engine -> pipeline"]
end
check -.生成.-> wp_identity
engine -.生成.-> wp_identity
pipeline -.生成.-> wp_identity
check -.保留.-> wp_rule_ctx
check -.保留.-> wp_sample_ctx
engine -.保留.-> wp_rule_ctx
engine -.保留.-> wp_source
pipeline -.保留.-> wp_runtime_ctx
wp_identity --> wp_policy
wp_category --> wp_policy
wp_policy --> boundary
boundary --> user_view["规则开发者视图<br/>错误位置 + 修复提示"]
boundary --> ops_view["运维视图<br/>指标 + 告警 + 失败分类"]
boundary --> debug_view["调试视图<br/>脱敏上下文 + source chain"]
这张图表达的核心原则:WarpParse 的高性能解析和错误治理必须同时存在。orion-error 提供错误治理基础设施,WarpParse 验证了这套方法论在工业级高吞吐 ETL 系统中的可用性。
面向 AI 的工程化复用
Wukong 错误治理模型不应只停留在文档里。更有效的做法是把方法论、设计原则、crate/lib 使用规范、示例代码、反模式和迁移规则整理成可复用的 engineering skills。Orion 体系中的 skills 沉淀在 orion-skills 仓库:https://github.com/galaxio-labs/orion-skills
AI 可基于这些 skills 产出项目级错误设计文档。例如 Warp Insight 的错误处理系统设计文档:https://github.com/wp-labs/warp-insight/blob/main/doc/design/foundation/error-handling-system.md 。这类文档的价值不只是记录错误类型,而是让 AI 和工程师围绕同一套治理模型讨论分类、传播、边界输出、观测和迁移。
这样 AI 不再只是临时生成几段错误处理代码,而是围绕一套明确的治理模型工作。面对新项目时,AI 先识别错误边界、语义域、稳定错误标识、诊断链和边界输出,再给出治理规划;进入实现阶段时,按约定使用 orion-error,把 reason 定义、source 保留、context 挂载、exposure 策略和测试断言落到代码里。
skills 把错误治理从“靠经验提示 AI“变成“给 AI 一套工程约束“:
- 规划阶段:识别 L0/L1/L2 现状,设计分类契约和迁移路径。
- 设计阶段:划分语义域,定义 reason、identity、category 和治理属性。
- 实现阶段:选择首次进入、跨层收敛、语义边界包装和边界输出的正确 API。
- Review 阶段:检查是否丢失 source、是否依赖错误文案、是否在 handler 中重复拼响应、是否缺少错误标识测试。
- 迁移阶段:把字符串错误、临时 enum、泛化包装逐步收敛为稳定的契约与诊断结构。
错误处理横跨架构、协议、观测、测试和团队规范,单靠一次 prompt 难以稳定完成。把方法论和库约束沉淀为 skills 后,AI 才能在不同项目中复用同一套工程判断,生成一致、可维护的实现代码。
附录:语言机制与生态采纳
方法论与语言无关,但不同语言落地成本不同。区分两个维度:
- 语言表达能力:语言是否方便表达稳定分类、结构化载体、原因链和边界输出。
- 生态采纳成本:团队在既有生态中采用这套治理需要付出的组织和迁移成本。
亲和度高不等于采纳容易。Rust 类型系统非常适合这套模型,但错误生态路径较多;Go 类型表达能力弱,但显式 error 返回高度统一,引入轻量分类规范的组织成本反而可能更低。
无论使用哪种语言,落地时都应回答同一组问题:
- 稳定错误标识放在哪里,是否能跨版本保持兼容?
- 诊断链如何保留,跨层转换时是否会丢失根因?
- context 和 detail 放在哪里,是否会污染治理分类?
- 边界策略在哪里集中,HTTP/RPC/CLI/log/metric 输出是否一致?
- 进入日志、协议、标准错误接口或第三方框架时,哪些信息被保留、脱敏或丢弃?
Rust — 原生匹配
Rust 同时满足三项:代数类型(enum)表达分类,match 提供穷尽检查;泛型提供类型安全的载体参数化;无异常机制,错误通过返回值传递,自然与载体配合。
但 Rust 的实际采纳并不简单。生态中长期存在 failure、error-chain、anyhow、thiserror、eyre 等不同取向:有的偏快速传播,有的偏诊断报告,有的偏领域错误定义。团队仍需明确边界:哪些层用结构化治理错误,哪些边界允许快速错误聚合,哪些错误标识进入长期契约。
TypeScript — 亲和度高
type AppErrorClass =
| { kind: "not_found"; id: string }
| { kind: "system_error" };
Union type + discriminated union 天然适合错误分类。neverthrow、fp-ts 的 Either 等库提供了返回值式错误处理。弱点是运行时类型信息有限,跨进程、跨包、跨 JSON 边界时仍需显式 runtime tag、schema 或协议字段保存错误标识和分类。
Swift — 亲和度高
代数类型(enum with associated values)表达错误分类。Result<T, E> 在 Swift 5.0+ 中原生支持。社区中有用 Result 替代 throws 的实践。
C# — 需要映射到异常生态
泛型支持良好(运行时保留类型信息),但异常机制主导生态。缺原生 discriminated union(可用 OneOf 模拟)。更自然的映射不是强行改成 Result,而是用异常类型层次表达分类、inner exception 保留原因链、ASP.NET Core 中间件做集中策略。
Java — 需要映射到框架约定
泛型擦除,异常机制主导。但 cause chain 机制成熟,Spring 的 @ControllerAdvice、filter、interceptor 已是集中策略的常见模式。Java 17+ 的 sealed class、record 和 pattern matching 让有限分类表达比过去更自然。
核心映射:每个语义域定义独立的 sealed class,域之间无继承关系。 和 Rust 中 RepositoryReason 与 OrderReason 是两个独立 enum 同理,Java 中 RepositoryError 与 OrderError 是两个独立 sealed class。跨域时构造新域的异常,把旧域异常作为 cause 保留,而非向上转型到共同父类。
// data access 语义域(精简示意)
public sealed abstract class RepositoryError extends RuntimeException
permits RepositoryError.ConnectionFailed, ... {
public abstract String identity(); // e.g. "repository.connection_failed"
public abstract String category(); // e.g. "system"
public abstract boolean retryable();
private DiagnoseContext ctx;
protected RepositoryError(String detail, Throwable cause, DiagnoseContext ctx) {
super(detail, cause);
this.ctx = ctx;
}
public static final class ConnectionFailed extends RepositoryError {
public ConnectionFailed(String detail, Throwable cause, DiagnoseContext ctx) { super(detail, cause, ctx); }
public String identity() { return "repository.connection_failed"; }
public String category() { return "system"; }
public boolean retryable() { return true; }
}
}
跨域传播时,Service 层构造 OrderError,将 RepositoryError 作为 cause:
catch (RepositoryError e) {
throw new OrderError.DependencyUnavailable("submit order failed", e, ctx);
}
此时 cause chain:OrderError.DependencyUnavailable → RepositoryError.ConnectionFailed → SQLException。边界层由 @ControllerAdvice 集中路由:@ExceptionHandler(OrderError.class) 基于 identity() 决定状态码和响应体。
| 概念 | Rust | Java |
|---|---|---|
| 语义域分类 | enum RepositoryReason | sealed class RepositoryError |
| 契约通道 | reason 变体 + identity 字符串 | 子类覆写的 identity() / category() / retryable() |
| 诊断通道 | StructError 的 detail / context / source | getMessage() / context record / getCause() |
| 统一载体 | StructError<R> 泛型参数化 | 不可行 —— JLS 禁止泛型类继承 Throwable |
| 跨域转换 | source_err(...) 一行 | 显式 try-catch 构造新异常 |
Java 的硬约束:JLS 禁止泛型类继承 Throwable,因此无法用单一 StructuredError<R> 统一所有域;跨域转换必须显式 try-catch。核心架构思想一致:独立语义域、稳定错误标识为主键、诊断链通过 cause 保留、边界策略集中路由。
C++ — 技术可行,生态无约定
模板保留类型信息,std::expected(C++23)提供类似 Result 的机制,Boost.Outcome 等库也提供了更完整的结果/错误建模。但 C++ 错误处理长期并存异常、错误码、expected、Outcome、自定义 status 等多条路径,生态无主导载体。技术可行,组织统一成本高。
Go — 需要更多团队约束
error 接口默认只要求 Error() string,结构化信息需通过自定义 error 类型、errors.Is/errors.As 和 wrapping 额外建立。Go 不是不能做错误治理,而是生态默认路径偏轻量包装,治理约束需团队主动设计。
两个维度的对比
| 语言 | 语言表达能力 | 生态采纳成本 | 主要原因 |
|---|---|---|---|
| Rust | 高 | 中 | 类型系统匹配,但错误生态路径较多,需团队约定边界 |
| Swift | 高 | 中 | enum/Result 表达自然,但 throws 仍是重要生态路径 |
| TypeScript | 中高 | 中 | discriminated union 方便,但运行时需 schema/tag 补足 |
| C# | 中 | 中 | 泛型和中间件成熟,但异常生态主导,DU 需模拟 |
| Java | 中 | 中 | cause chain 和框架边界成熟,sealed class 改善分类表达 |
| C++ | 中高 | 高 | 类型能力强,但错误处理路径分裂,组织统一成本高 |
| Go | 低中 | 中低 | 类型表达较弱,但显式 error 返回高度统一,轻量分类规范易推广 |
这个表只描述落地摩擦,不评价语言优劣。真正决定错误治理质量的,往往不是语言本身,而是团队是否建立了稳定错误标识、诊断保留、边界策略和演进规则。
总结
错误处理是系统从原型走向工业级应用的分水岭。原型只需证明正确路径能跑通;工业级系统须在输入变化、依赖退化、配置漂移、数据异常、规则演进和运行环境波动下继续保持可运行、可诊断、可恢复、可演进。
本文提出的 Wukong 错误治理模型 把错误信息拆成两条通道:
- 契约通道:稳定错误标识、稳定分类、category、retryable、暴露等级,用于调用方决策、协议输出、监控告警、SLA 统计和长期兼容。
- 诊断通道:原因链、上下文、细节、底层错误,用于排障、调试、规则修复、运行观测和系统演进。
两条通道解决的核心矛盾:错误在治理层面必须收敛,否则无法自动化决策;在诊断层面必须保真,否则无法定位根因。成熟的错误体系不能只追求“包装得漂亮“,也不能只依赖语言机制,而要明确稳定错误标识如何演进、诊断链如何跨层保留、边界策略如何集中输出、外部生态桥接时保留和丢弃什么。
在 Rust 中,orion-error 将这套模型落成可复用基础设施:StructError<R> 承载契约与诊断信息,领域 reason 提供稳定分类契约,source chain 和 context 保留诊断路径,exposure/report/interop 完成不同边界的输出和桥接。orion-error/examples/order_case.rs 给出了小型可运行例子,WarpParse 提供了工业级验证:在高吞吐 ETL 系统中,错误治理直接影响规则开发体验、运行时可观测性、边界输出质量和长期运维成本。
错误治理不是异常语法的附属品,也不是日志格式的局部优化。它是工业级系统的信息架构之一,也是系统对抗腐化与脆弱的骨架。只有当失败路径也具备稳定分类、完整诊断、集中输出和可演进契约时,系统才真正从“能跑“走向“可长期运行“。
Wukong 错误治理模型:降服千变万化的错误
一个工业级系统和原型的区别,不只在正确路径上,更在失败路径上。
本文将先梳理核心矛盾,再给出 Wukong 错误治理模型的解决思路,最后看 Rust 落地和工业验证。如果你只关心方法论本身,可以直接跳读到 我们的方案。
你有没有见过这样的代码库?
同一个“订单不存在“,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?借用的正是悟空在西天路上一路降妖除魔的意象。放到软件开发里,我们要降伏、封印的是各种错误:IO 错误、配置错误、网络错误、业务规则错误、数据质量错误。它们不能继续以散乱字符串、隐式包装和边界泄露的形式到处作乱,而必须被纳入一套可命名、可诊断、可约束、可演进的治理体系。
错误内部模型 = 契约通道 + 诊断通道
适配输出 = 对内部模型按策略生成的不同输出视图
契约通道 —— 包含稳定错误标识、稳定分类、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;用户边界只应该看到稳定错误码、可公开消息和必要修复提示;观测系统则接收脱敏后的诊断摘要和聚合标签。
错误传播的三种模式
一个工业级错误经历三种动作,不是机械向上抛。
首次进入。 原始错误(IO、网络、解析)首次进入结构化体系,同时完成:选择分类、给出当前层解释(detail)、保留原始错误为 source。
跨层转换。 同语义域收敛分类,跨语义域建立新边界包裹。取决于当前层是否是新语义边界。
适配输出。 在系统边界交给集中策略,为不同接收端生成不同输出视图。不再重新解释错误。
三层下来,契约通道给调用方的是稳定错误标识和治理决策,诊断通道保留了完整的 cause chain + context + detail。排障方能追溯根因,调用方不需要知道底层细节。
Rust 落地:五个设计规则
Rust 的类型系统天然适合这套模型——代数类型表达分类,match 提供穷尽检查,泛型参数化载体,无异常机制让错误通过返回值传递。但 Rust 不会自动完成治理,需要在工程层面建立规则。
规则一:按语义域定义 Reason,而非一个全局大枚举。 RepositoryReason 和 OrderReason 各自在自己的边界内约束分类空间,跨域时显式转换。
规则二:首次进入即结构化。 IO、网络、解析错误第一次进入体系时,同时完成:选择分类 + 给出 detail + 保留 source。不把底层错误转成字符串。
规则三:跨语义域建立新边界,保留下层为 source。 同域收敛,跨域包裹——判断依据是当前层是否有新的业务含义。只做 reason 收敛时用 conv_err();建立新语义边界时用 source_err(...)。
规则四:边界只做输出,不重新解释。 handler 调用 exposure(&policy),策略集中决定 HTTP 状态码、用户消息、日志级别——不在每个 handler 里重复决策。
规则五:测试错误标识,不测试错误文案。
#![allow(unused)]
fn main() {
assert_eq!(err.identity_snapshot().code, "order.submit_dependency_unavailable");
assert_eq!(exposed.decision.http_status, 503);
}
我们在 Rust 下把 Wukong 错误治理模型实现为 orion-error crate(开源),完整示例见 orion-error/examples/order_case.rs。这个示例主要展示 conv_err() 收敛路径;如果上层需要建立新的业务语义边界,应使用 source_err(...) 把下层结构化错误保留为 source。
工业验证:WarpParse
方法论需要真实系统检验。WarpParse 是 Orion 体系中的高吞吐日志解析引擎,在 benchmark 中对比主流方案取得了显著领先。但吞吐量本身不证明错误治理质量,真正被验证的是:
- 规则错误能否定位到文件、行列和字段。
- 配置错误能否阻断发布,而不是触发系统故障告警。
- 数据质量错误能否聚合统计,而不污染系统错误。
- 运行时错误能否区分可重试、不可重试和需要人工介入。
- 用户视图、运维视图和调试视图是否来自同一个稳定错误标识。
没有结构化错误时,规则语法错误只是一段字符串 unexpected token at line 12——开发者仍需打开文件、定位行列、猜测问题。引入 Wukong 错误治理后,同一次失败被表达为:
identity : rule.syntax
category : config
context : { rule_file, line, column, field, expected_token, actual_token }
policy : block activation, show repair hint, do not page SRE
规则开发者拿到位置和修复线索,运行系统拿到稳定错误标识和策略,运维侧可把配置错误、数据错误、系统错误分开统计和告警。
吞吐越高,失败路径越需要结构化能力。 否则处理能力越强,错误扩散和排障成本也被同步放大。
总结
错误处理排在功能实现、算法性能、代码可读之后——但它决定了系统能不能长期稳定运行。从 1997 年被异常机制吸引,到今天把 Wukong 错误治理模型落地,我花了二十多年。
核心答案就一条:把契约和诊断拆成两条通道,再为不同接收端生成适配输出。 契约通道提供稳定错误标识和分类,让机器做决策;诊断通道保留原因链和上下文,让人能定位。成熟度不取决于选了 Rust 还是 Java,而取决于你的架构有没有四根承重墙——稳定错误标识、有限分类空间、诊断保留、集中边界策略。
当失败路径也具备稳定分类、完整诊断、集中输出和可演进契约时,你的系统才真正从“能跑“走向“可长期运行“。
本文是 Orion 错误治理方法论的主体文档。可运行示例见 orion-error/examples/order_case.rs。方法论、设计原则和迁移规则已沉淀为 engineering skills,见 orion-skills。
结构化错误如何改善 AI 代码生成
错误处理的成本不在代码量
行业数据显示,缺陷定位和修复占据 40-60% 的工程成本(Hamill & Goseva-Popstojanova 引用 Cambridge 报告估计开发者约 50% 时间用于 finding and fixing bugs;Capers Jones 总结该比例常超过 60%)。但另一组数据更有意思:Cabral 和 Marques 对 32 个 Java/.NET 应用的 field study 显示,异常处理代码只占源码的 3-7%。
成本不在“写错误处理代码“,而在失败发生后,缺乏结构化信息去定位、分类和决策。Rust 中没有异常,没有 try-catch,每一处 ? 都是一个传播决策点。如果这些决策没有结构,错误路径随代码规模一起失控。
这个调用会返回什么错误?→ 我应该拦截还是传播?→
如果拦截,新错误属于什么分类?→ 要不要保留原始错误?→
要到边界了吗?→ 暴露给调用方的正确格式是什么?
每一层都重新做这些决策,不同开发者的答案往往不一致。
为什么结构化错误更适配 LLM
orion-error 解决错误治理核心矛盾(收敛 vs. 诊断)的方式是把分类信息收敛到 reason,诊断信息保留在 source chain + context。详见《双通道:工业级系统的错误治理模型》。
对 AI 编程而言,这个分离有四个直接收益。
结构即提示
LLM 通过模式识别生成代码。当错误处理是结构化的,模型更容易推断正确输出。
无结构:
#![allow(unused)]
fn main() {
// AI 难以判断这里该用什么错误类型
fn load_config() -> Result<Config, Box<dyn Error>> {
let text = std::fs::read_to_string("config.toml")?;
let cfg = toml::from_str(&text)?;
Ok(cfg)
}
}
模型需要猜测:Box<dyn Error> 里具体是什么?调用方怎么处理?
有结构:
#![allow(unused)]
fn main() {
fn load_config() -> Result<Config, StructError<ConfigReason>> {
let text = std::fs::read_to_string("config.toml")
.source_err(ConfigReason::ReadFailed, "read config file")
.doing("load config")?;
let cfg = toml::from_str(&text)
.source_err(ConfigReason::ParseFailed, "parse config")
.doing("parse config")?;
Ok(cfg)
}
}
reason 变体是显式的,模型和开发者都围绕有限分类做选择。source_err + doing 是固定模式,比自由拼接错误字符串更容易生成、检查和 review。
分类空间收敛
UnifiedReason 提供了预置分类(validation / system / network / timeout / config…)。通用技术失败先有默认分类,领域失败再补充项目自己的 reason。模型和开发者不必从零设计分类体系,而是在预置集合和领域 reason 中做受约束的选择。
#![allow(unused)]
fn main() {
#[derive(OrionError)]
enum AppReason {
#[orion_error(identity = "biz.xxx")] // 领域特有
SpecificError,
#[orion_error(transparent)]
General(UnifiedReason), // 通用兜底
}
}
这给代码生成留下了稳定模板:业务变体补 identity,通用失败通过透明变体复用 UnifiedReason。真正的业务语义仍需人 review。
边界投影消除最后一层决策
AI 代码最难做对的部分是协议边界的错误输出:把内部 detail 暴露给用户、HTTP 状态码给错、不同协议输出不一致。ExposurePolicy 把这个决策集中到一处:
#![allow(unused)]
fn main() {
impl ExposurePolicy for MyPolicy {
fn http_status(&self, identity: &ErrorIdentity) -> u16 {
match identity.code.as_str() {
"biz.not_found" => 404,
"biz.invalid" => 400,
_ => 500,
}
}
}
}
AI 生成代码时只需遵循同一个 policy 调用模式;状态码、visibility、retryable、hints 仍由团队定义和 review。
测试路径可推断
结构化错误的测试断言也是可推断的:
#![allow(unused)]
fn main() {
// AI 可以可靠生成这种测试
let err = function_that_fails().unwrap_err();
assert_err_identity(&err, "biz.not_found", ErrorCategory::Biz);
assert_err_operation(&err, "load config");
}
而不是:
#![allow(unused)]
fn main() {
// AI 需要猜测错误消息的确切字符串
let err = function_that_fails().unwrap_err();
assert!(err.to_string().contains("not found")); // 脆弱
}
LLM 在两种范式下的表现
| 任务 | 传统方式(自由字符串/即兴决策) | 结构化方式(枚举/分类空间/Policy) |
|---|---|---|
| 选择错误类型 | 需猜测,易出错 | 从有限枚举中选择 |
| 写错误文案 | 每处不同,不可控 | 模板化模式 |
| 边界输出 | 各 handler 自行决定 | Policy 集中决策 |
| 测试错误路径 | 依赖字符串匹配,脆弱 | 断言 identity,稳定 |
核心差异:结构化错误把“让模型自由发挥“转为“让模型在有限选项中做选择,并受类型、测试和 review 约束“。
更深的影响
从生成代码到生成决策。 当错误路径是枚举变体而非自由字符串时,AI 不再只是写错误文案,而是在受限集合里选择分类。分类选择仍可能出错,但比自由生成更可控,更容易通过类型、测试和 review 约束。
错误路径覆盖率。 AI 代码最易被忽视的就是错误路径——训练数据中错误路径占比远低于正常路径。结构化错误通过固定模式(source_err + doing + conv_err)把错误路径写成可重复模板。模型识别出“这个调用可能失败“后,有明确的 API 路径可走。
跨层一致性。 多人协作的代码库中,不同开发者对同一错误的处理方式往往不一致——AI 在不同上下文中也可能给出不同风格。结构化治理通过集中决策(reason 定义 + policy 实现)把一致性要求前移,人写的代码和模型生成的代码围绕同一套约束工作。
局限
- 前期建模仍需人工。 Reason 分类和 Policy 定义是契约,不能由 AI 生成——它们需要人对业务和架构的判断。
- 存量迁移不在 AI 当前能力范围内。 把 L0 的字符串错误重构成结构化体系涉及类型变更和语义判断,AI 只能辅助不能主导。
- 领域语义选择仍会出错。 模型可能在“这个失败属于 config 还是 system“上判断失误,需要 review 和测试约束来兜底。
总结
orion-error 的结构化错误模型与 AI 编程的匹配不是偶然的。两者都受益于同一个原则:把隐式决策变成显式结构。 隐式决策依赖上下文理解(人和模型都容易犯错),显式结构依赖模式识别(结构化数据 + 固定分类空间对 LLM 是理想场景)。
这可能是 Rust 错误治理的一个方向:不是设计更聪明的错误类型,而是设计让错误处理决策变得更可预测、可枚举的系统——对人如此,对 AI 也是如此。
设计约束
跨 StructError 的 From 转换:orphan rule 限制
问题
跨层错误转换(StructError<ParseReason> → StructError<OrderReason>)需要调用 .conv_err()。
#![allow(unused)]
fn main() {
// 期望但不能实现
fn place_order() -> Result<OrderDraft, StructError<OrderReason>> {
let draft = parse_order()?; // 期望自动 From<ParseError> → OrderError
Ok(draft)
}
// 实际需要显式调用
fn place_order() -> Result<OrderDraft, StructError<OrderReason>> {
let draft = parse_order().conv_err()?; // 显式转换
Ok(draft)
}
}
原因
Rust 的 orphan rule 不允许从下游 crate 中实现 From<Foreign<Local>> for Foreign<Local2>:
#![allow(unused)]
fn main() {
// 这行代码在用户 crate 中展开
impl From<orion_error::StructError<UserLocalReason>> // Foreign<Local>
for orion_error::StructError<UserLocalReason2> // Foreign<Local2>
}
From= 标准库 trait(外来)StructError= orion-error 的类型(外来)- 即使
LocalReason和LocalReason2是本地类型
Orphan rule 要求 trait 或 self type 至少有一个本地锚点,但这里 From 是外来 trait,StructError<_> 也是外来类型。
即使 LocalReason 和 LocalReason2 是本地类型,From<StructError<Local>> for StructError<Local2> 仍然不能在下游 crate 里实现。
已经尝试过的方案
| 方案 | 结果 |
|---|---|
下游 crate 直接 impl From<StructError<A>> for StructError<B> | ❌ orphan rule |
derive 属性 upcast_from(SubReason) 在目标类型上 | ❌ orphan rule |
derive 属性 upcast_to(MainReason) 在源类型上 | ❌ orphan rule |
让 ? 自动触发跨 reason 转换 | ❌ 不能靠 From 达成 |
newtype struct AppError(StructError<T>) | ✅ 可行,但所有 API 返回类型都需要改 |
结论
.conv_err() 是唯一的路径。
newtype 可以绕过 orphan rule,但代价太大——为了省一次显式调用而把所有函数返回类型包一层,收益远低于成本。
Rust 的 orphan rule 是生态兼容性的核心保证,短期内不会为此放宽。
orion-error 0.8.0 架构
本文描述的是 orion-error 0.8.0 的理想设计架构:它解释公开 API 背后的设计约束、核心数据流和治理目标。文中的结构体片段用于说明模型边界,不等同于源码逐字段快照;精确字段以 src/ 中实现为准。
问题
大型 Rust 服务中的错误处理,有五个未满足的需求:
- 收敛不丢信息。 下层技术错误需要抽象成上层稳定语义,但原始根因(source chain、detail、context)必须保留给排障使用。
- 跨层传播。 错误经过多层(handler → service → repository → database),每层都需要附着自己的上下文,但不能丢弃前面的信息。
- 边界投影。 同一个错误面向不同对象必须有不同视图:最终用户(安全消息)、运维(组件 + 可重试性)、协议客户端(稳定 code + 结构)、开发者(完整链)。
- 可治理的稳定身份。 错误需要稳定、机器可读的 identity,在重构后仍保持不变,贯穿 HTTP/RPC/日志/CLI 边界。
- 结构化载体。 错误携带 detail、source chain、操作上下文和 metadata,全部是结构化字段,而不是字符串拼接。
现有方案各自解决了一部分:
| 库 | 优势 | 未覆盖 |
|---|---|---|
thiserror | 本地错误 enum 建模,生成 Display + From | 跨层传播、上下文附着、协议投影 |
anyhow | 应用层错误统一,context() | 稳定 identity、协议输出、细粒度分类路由 |
color-eyre | 丰富的诊断报告 | 同 anyhow——无协议或 identity 层 |
orion-error 瞄准的是这个空白:大规模治理——错误经过 3-5 层后,在协议边界以稳定结构输出的场景。
核心洞察:Reason/Carrier 分离
最核心的设计决策是:把错误的语义分类(reason)和传播机制(carrier)分离。
#![allow(unused)]
fn main() {
// reason = 这是什么类型的错误
enum AppReason {
InvalidInput,
OrderNotFound,
General(UnifiedReason),
}
// carrier = 它怎么传播
let err: StructError<AppReason> = AppReason::OrderNotFound
.to_err()
.with_detail("order #42 not found")
.with_source(db_error)
.with_context(ctx);
}
为什么要分离?
如果 reason 和 carrier 合在一起——就像典型的 thiserror enum 用法——每个运行时机制(context 附着、source 追踪、协议投影)都得在每个 enum 上重新实现。carrier (StructError<T>) 只需要实现一次。
reason 保持轻薄——只需要实现 DomainReason marker trait:
#![allow(unused)]
fn main() {
pub trait DomainReason: PartialEq + Display + Debug + Send + Sync + 'static {}
}
| 约束 | 原因 |
|---|---|
Display + Debug | 错误必须可打印,用于诊断和日志 |
PartialEq | 支持测试中断言 |
Send + Sync | StructError 需要跨 async 任务边界,能被 anyhow::Error 或 Box<dyn Error> 捕获 |
'static | 支持类型擦除 (dyn Error) 和 SourceFrame 存储 |
错误流转
raw std error ──→ .source_err(reason, detail) ──→ 首次进入结构化系统
│
conv_err()
(reason 重新映射)
│
report / exposure / display_chain
1. 入口:source_err(reason, detail)
统一入口,同时支持原始 std::error::Error 和已结构化的 StructError 源:
#![allow(unused)]
fn main() {
let result = std::fs::read_to_string("config.toml")
.source_err(AppReason::system_error(), "read config failed")?;
}
- 原始错误作为 source frame 存储,保留 Display 和 Debug 输出
reason成为错误的稳定分类detail提供当前层的解释
2. 跨层转换:conv_err()
当上游已是 StructError<R1>,只需要改变 reason 类型时使用:
#![allow(unused)]
fn main() {
fn upper_layer() -> Result<(), StructError<UpperReason>> {
lower_layer().conv_err()?;
Ok(())
}
}
需要 UpperReason: From<LowerReason>。所有 detail、context、source chain、metadata 都保留。
From<StructError<R1>> for StructError<R2> 的 blanket impl 被 Rust 的 orphan rule 阻止(From 和 StructError 都不属于用户 crate),因此使用显式 trait 方法。
3. 首次进入 vs 跨层转换
| 方法 | 语义 | Source 保留方式 |
|---|---|---|
source_err(reason, detail) | 创建新的语义边界 | 作为未结构化或结构化 source 包裹 |
conv_err() | 只重新映射 reason 类型 | 保留所有 detail、context、source、metadata |
核心类型
StructError<T: DomainReason>
统一的运行时载体。概念上它把 reason 和运行时传播数据装进一个小尺寸 carrier:
#![allow(unused)]
fn main() {
pub struct StructError<T: DomainReason> {
imp: Box<StructErrorImpl<T>>,
}
}
Box 用于保持 StructError 足够小(指针大小),因为它经常通过 Result 返回。
StructErrorImpl<T>
存储错误传播所需的数据。简化模型如下:
#![allow(unused)]
fn main() {
struct StructErrorImpl<T> {
reason: T,
detail: Option<String>,
position: Option<String>,
context: Option<Arc<Vec<OperationContext>>>,
source_payload: Option<InternalSourcePayload>,
}
}
关键决策:
context: Option<Arc<Vec<...>>>— 惰性分配:没有 context 的错误不产生堆分配。Arc使 context chain 可以廉价 cloneBox<StructErrorImpl<T>>—StructError自身保持小尺寸(一个指针),最小化Result的大小
OperationContext
运行时上下文载体。概念上它描述“当前层正在做什么、访问什么、附带哪些诊断字段、是否触发日志输出”等信息:
#![allow(unused)]
fn main() {
pub struct OperationContext {
action: Option<String>,
locator: Option<String>,
fields: Vec<(String, String)>,
path: Vec<String>,
metadata: ErrorMetadata,
result: OperationResult,
exit_log: bool,
}
}
doing(...)— 正在执行什么操作(“load config”, “validate order”)at(...)— 正在访问什么资源(“config.toml”, “order #42”)with_field(...)— 人可读的诊断字段with_meta(...)— 机器消费的结构化 metadata(仅用于序列化)success()/fail()/cancel()与日志方法 — 让调用方用少量代码记录操作结果
SourceFrame
表示 source chain 中的一个元素。简化模型如下:
#![allow(unused)]
fn main() {
pub struct SourceFrame {
pub index: usize,
pub message: SmolStr,
pub display: Option<SmolStr>,
pub debug: Option<SmolStr>,
pub type_name: Option<SmolStr>,
pub error_code: Option<i32>,
pub reason: Option<SmolStr>,
pub path: Option<SmolStr>,
pub detail: Option<SmolStr>,
pub metadata: ErrorMetadata,
pub is_root_cause: bool,
pub context_fields: Vec<(SmolStr, SmolStr)>,
}
}
字符串字段使用 SmolStr(短字符串零分配优化),使 source chain 遍历时的 clone 更快。
消费路径
三个独立的消费出口,各自返回同一错误的不同视图:
report() → DiagnosticReport
人类可读的诊断信息。只要求 DomainReason。
#![allow(unused)]
fn main() {
let report: DiagnosticReport = err.report();
println!("{}", report.render());
}
输出:
reason: system error
detail: read config failed
context:
[0] place_order [user_id: 42]
exposure(&policy) → ErrorProtocolSnapshot
协议边界投影。需要 ErrorIdentityProvider(由 #[derive(OrionError)] 提供)。
#![allow(unused)]
fn main() {
let proto = err.exposure(&MyPolicy);
let http_json = proto.to_http_error_json()?; // {"status": 500, "code": "sys.io_error", ...}
let log_json = proto.to_log_error_json()?; // 完整结构化日志输出
let cli_json = proto.to_cli_error_json()?; // 面向运维的摘要
let rpc_json = proto.to_rpc_error_json()?; // 面向上游的协议输出
}
ExposurePolicy trait 控制决策:
| 方法 | 默认值 | 覆盖频率 |
|---|---|---|
http_status() | 500 | 最常见 |
visibility() | Internal (Biz → Public) | 常见 |
retryable() | false | 偶尔 |
default_hints() | [] | 很少 |
Visibility 控制哪些错误信息到达外部调用方:
Public | Internal | |
|---|---|---|
HTTP message | 使用 detail | 使用 reason(隐藏 detail) |
RPC detail | 暴露 | null |
display_chain() → 格式化字符串
Source chain 展开,用于排障。不要求额外 trait。
system error
-> Info: read config failed
-> Caused by:
1. outer source
2. inner source
identity_snapshot() → ErrorIdentity
稳定身份识别,不涉及协议投影:
#![allow(unused)]
fn main() {
let id = err.identity_snapshot();
assert_eq!(id.code, "sys.io_error");
}
UnifiedReason
UnifiedReason 是内置的通用错误分类,覆盖大多数服务中都会出现的错误类别:
| 分类 | 编码范围 | 示例 |
|---|---|---|
| 业务 | 100-105 | validation_error, not_found |
| 基础设施 | 200-204 | system_error, network_error, timeout |
| 配置与外部 | 300-301 | core_conf, external_error |
设计为不需要领域特化 reason 时的兜底。领域 enum 通常把它作为透明变体包含:
#![allow(unused)]
fn main() {
#[derive(OrionError)]
enum AppReason {
#[orion_error(identity = "biz.invalid")]
Invalid,
#[orion_error(transparent)]
General(UnifiedReason),
}
}
#[orion_error(transparent)] 属性将 stable_code()、error_category() 和 Display 委托给内部的 UnifiedReason。
显式 StdError 桥接
StructError<T> 不实现 std::error::Error。这是有意为之:
- 防止意外的类型擦除。 如果
StructError实现了StdError,调用代码可能通过.into()或Box<dyn Error>无意中擦除 reason 类型,丢失结构化 identity。 - 让边界跨越保持显式。 当需要与
StdError生态互操作时,转换是显式的:
#![allow(unused)]
fn main() {
let std_ref: StdStructRef<'_, AppReason> = err.as_std();
let owned: OwnedStdStructError<AppReason> = err.into_std();
let dyn_owned: OwnedDynStdStructError = err.into_dyn_std();
}
Derive 宏
#[derive(OrionError)] 自动生成核心 trait 实现:
| Trait | 用途 | 来源 |
|---|---|---|
Display | 人类可读的错误信息 | 从 message 属性生成,或从 identity 自动推导 |
DomainReason | Carrier 兼容性 | 空的 marker 实现 |
ErrorCode | 兼容旧系统的传统数值编码 | 从 code 属性生成,或默认 500 |
ErrorIdentityProvider | 稳定 code + category | 从 identity 和 category 属性生成 |
属性
| 属性 | 是否必需 | 生成 |
|---|---|---|
identity = "biz.foo" | 是(除非 transparent) | stable_code() 返回 "biz.foo" |
category = Biz | 否(从 identity 前缀推断) | error_category() 返回指定分类 |
transparent | identity 的替代 | 将所有方法委托给内部类型 |
message = "..." | 否(从 identity 自动生成) | 自定义 Display 输出 |
code = ... | 否(默认 500) | 传统数值 error_code() |
协议、日志聚合和监控应以 ErrorIdentity.code / stable_code() 作为稳定身份。ErrorCode 是数字码兼容层,不应作为新的外部协议主键。
透明变体构造器委托
当 enum 包含透明变体且包装了 UnifiedReason 时,所有 UnifiedReason 构造器会自动生成为该 enum 的方法:
#![allow(unused)]
fn main() {
#[derive(OrionError)]
enum AppReason {
#[orion_error(transparent)]
General(UnifiedReason),
}
// 自动生成:
AppReason::system_error() // 而不是 AppReason::General(UnifiedReason::system_error())
AppReason::validation_error()
AppReason::not_found_error()
}
第三方错误集成
第三方错误类型通过 source_err() 进入结构化系统。支持的类型:
| 类型 | Feature | 机制 |
|---|---|---|
std::io::Error | 内置(无需 feature) | 直接 UnstructuredSource 实现 |
serde_json::Error | serde_json | 直接 UnstructuredSource 实现 |
anyhow::Error | anyhow | 尝试结构化恢复,失败则退化为未结构化 source |
toml::de::Error | toml | 直接 UnstructuredSource 实现 |
| 自定义类型 | — | 通过 RawStdError + raw_source() 显式 opt-in |
Opt-in 设计(RawStdError)防止静默的结构化到未结构化降级:
impl RawStdError for MyError {}
let result: Result<(), MyError> = Err(MyError);
let err = result
.map_err(raw_source)
.source_err(AppReason::system_error(), "my operation failed")?;
设计演化
命名:UvsReason → CommonReason → UnifiedReason
内置 reason 类型经历了三次命名:
UvsReason— 原始名称,含义不直观CommonReason— 中间改名,但 “Common” 听起来像“普通“而非“统一“UnifiedReason— 最终名称,反映其作用:具体错误收敛(统一)到这个分类
pub type UvsReason = UnifiedReason; 作为 deprecated 别名保留,用于迁移兼容。
Variant 命名:Uvs → General
领域 enum 中的透明变体更名为 General:
#![allow(unused)]
fn main() {
// 之前
Uvs(UnifiedReason),
// 之后
General(UnifiedReason),
}
General 比 Uvs 更清楚地表达“这是非领域特化错误的兜底“。
消费路径收敛:snapshot 不作为主路径
orion-error 0.8.0 的架构主路径是 report()、exposure()、display_chain() 和 identity_snapshot()。
稳定机器身份由 identity_snapshot() 提供;面向 HTTP/RPC/CLI/log 的结构化边界输出由 exposure() 和 ErrorProtocolSnapshot 提供;人类诊断由 report() 提供。这样可以减少一条独立 snapshot 类型体系带来的 API 面,同时保留稳定身份和协议投影能力。
API 命名:exposure
与 report() 保持一致。这个名字表达的是:“在边界按策略暴露这个错误”,而不是要求用户先理解内部快照模型。
Feature 门控
| Feature | 启用内容 | 默认 |
|---|---|---|
derive | 过程宏派生(OrionError、ErrorCode、ErrorIdentityProvider) | 是 |
log | OperationContext 日志方法(ctx.info()、.debug()、.warn()、.error())和 Drop 自动日志 | 是 |
tracing | Tracing 集成(同时启用时优先使用 tracing 而非 log) | 否 |
serde | 核心类型的 Serialize/Deserialize 支持 | 否 |
serde_json | 协议 JSON 投影方法(to_http_error_json() 等) | 否 |
anyhow | anyhow::Error 互操作(支持结构化 source 恢复) | 否 |
toml | toml::de::Error / toml::ser::Error 互操作 | 否 |
项目结构
src/
lib.rs — Crate 根,re-export,分层模块
core/
domain.rs — DomainReason trait
reason.rs — ErrorCode trait、ErrorCategory enum、ErrorIdentityProvider trait
universal.rs — UnifiedReason enum(内置分类)
error/
carrier.rs — StructError<T>、StructErrorImpl<T>
builder.rs — StructErrorBuilder<T>
identity.rs — ErrorIdentity struct、identity_snapshot()
source_chain.rs — SourceFrame、source payload 基础设施
std_bridge.rs — StdStructRef、OwnedStdStructError、OwnedDynStdStructError
context/
types.rs — OperationContext、OperationScope
convert.rs — ContextAdd trait
metadata.rs — ErrorMetadata、MetadataValue
report/
diagnostic.rs — DiagnosticReport、redaction
protocol.rs — ErrorProtocolSnapshot、ExposurePolicy、Visibility
traits/
contextual.rs — ErrorWith trait
conversion.rs — ConvErr、ConvStructError、ToStructError
source_err.rs — SourceErr、RawStdError、RawSource
testing.rs — 测试断言辅助
约束
Orphan Rule
From<StructError<R1>> for StructError<R2> 的 blanket 实现不能提供——From(std)和 StructError(本 crate)都不属于用户的 crate。显式的 conv_err() 方法是 intended path:
#![allow(unused)]
fn main() {
let result: Result<(), StructError<UpperReason>> = lower_result.conv_err()?;
}
Send + Sync
DomainReason 要求 Send + Sync。这是必要的——StructError 需要在 async 任务边界之间传递,并能被 anyhow::Error 或 Box<dyn Error> 捕获。对于单线程使用,这是一个微小但不可省略的约束。
0.8 API Contract
更新时间:2026-05-01
本文档固定 orion-error 0.8.x 的公开 API 契约。它描述当前承诺的主路径、
分层模块、feature-gated API、稳定快照和协议 JSON 边界。
如果本文档与 src/、tests/、examples/ 冲突,以代码和测试为准,并同步修正
本文档。
1. Root Exports
crate root 只承诺保留最小主路径入口:
StructErrorOperationContextUnifiedReason- derive feature 开启时的 derive 宏:
OrionErrorErrorCodeErrorIdentityProvider
root 不承诺重新暴露 reason trait、protocol type、report type、 interop type 或测试 helper。它们的正式归属在分层模块中。
ErrorCode 作为 derive 宏名字和兼容数值码能力存在;面向外部协议、日志、快照和
监控的稳定机器主键是 ErrorIdentity.code / stable_code()。
2. Prelude
orion_error::prelude::* 是新业务代码的推荐导入入口,当前承诺包含:
StructErrorErrorWithConvErrSourceErrUnifiedReason- derive feature 开启时的
OrionError
prelude 只放主传播路径需要的最小集合。协议、report、interop 和测试 helper
应从各自分层模块导入。
3. Layered Modules
分层模块是非 root 类型和 trait 的正式归属。
runtime运行时传播载体和上下文:StructError、StructErrorBuilder、OperationContext、OperationScope、WithContext、ErrorMetadata。runtime::sourcesource 观察模型:SourceFrame、SourcePayloadKind、SourcePayloadRef。conversion主路径转换 trait:SourceErr、ErrorWith、ConvErr、ConvStructError、ToStructError。reasonreason trait、分类和内置 reason:DomainReason、ErrorCode、ErrorIdentityProvider、ErrorCategory、UnifiedReason、ConfErrReason。report人类诊断与 redaction:DiagnosticReport、RedactPolicy。protocol协议/exposure 投影:DefaultExposurePolicy、ExposurePolicy、ExposureDecision、ErrorProtocolSnapshot、Visibility。interop标准错误生态互操作:StdStructRef、OwnedStdStructError、OwnedDynStdStructError、raw_source、RawSource、RawStdError。cliCLI 输出辅助:print_error(...)。dev::testing测试断言 helper,不属于业务主路径。dev::prelude协议/schema 测试和迁移验证用宽导入,不属于业务主路径。
bridge::* 不是 0.8 当前公开分层入口;标准错误生态边界统一称为 interop。
4. Source Attachment
source 挂载的推荐主路径是:
StructError::with_source(...)StructErrorBuilder::source(...)
调用者不需要区分 source 是普通 StdError 还是下层 StructError<_>;路由由 crate
内部完成。
以下 API 保留为维护旧代码、测试 source 分类或调试 auto-routing 的底层入口, 不作为教程和新业务代码的默认推荐:
with_std_source(...)with_struct_source(...)StructErrorBuilder::source_std(...)StructErrorBuilder::source_struct(...)
5. Error Flow
当前推荐的错误流转决策:
- 上游是普通错误,第一次进入结构化体系:
source_err(reason, detail)。 - 上游是
StructError<R1>,当前层只改变 reason 类型:conv_err()。 - 上游是
StructError<R1>,统一使用source_err(reason, detail)。 - 需要挂载 cause 到已有
StructError:with_source(...)或builder.source(...)。 - 需要进入
std::error::Error生态:as_std()、into_std()、into_boxed_std()、into_dyn_std()。
旧 owe(...) / owe_*() / err_wrap(...) / want(...) / with(...) 不属于
0.8 当前主 API。
6. Feature-Gated API
默认 feature:
logderive
可选 feature:
derive开启 root derive 宏 re-export,并启用#[derive(OrionError)]等宏。log开启log集成和OperationContextdrop 日志路径。tracing开启tracing集成;同时启用log和tracing时,drop 日志优先走tracing分支。serde开启主要结构的Serialize/Deserialize支持。serde_json开启 protocol JSON projection 方法:to_http_error_json()、to_cli_error_json()、to_log_error_json()、to_rpc_error_json()。anyhow开启anyhow::Error进入source_err(...)的适配,并支持官方 dyn interop wrapper 的结构化 source 恢复。toml开启toml::de::Error/toml::ser::Error进入source_err(...)的适配。
文档示例如果依赖 feature,应显式说明或用测试门控覆盖。
7. Protocol JSON
协议投影主入口:
identity_snapshot()exposure(...)into_exposure(...)ErrorProtocolSnapshot::to_http_error_json()ErrorProtocolSnapshot::to_cli_error_json()ErrorProtocolSnapshot::to_log_error_json()ErrorProtocolSnapshot::to_rpc_error_json()
ErrorProtocolSnapshot 的稳定输入由三部分组成:
identitydecision- embedded
DiagnosticReport
稳定承诺:
identity.code是协议、日志、监控、测试断言的稳定机器主键。identity.category是稳定分类。ExposureDecision的字段名和含义稳定:http_status、visibility、default_hints、retryable。- HTTP / CLI / log / RPC projection 的顶层用途稳定。
不承诺:
render_user_debug()的文本格式不是机器协议。- JSON 中用于人工排障的
summary/rendered_detail文本不作为精确稳定 schema。 source_frames的debug、display、type_name等诊断字段可能随实现调整。- 未在
docs/protocol-contract.md和测试中锁定的内部 helper 字段不作为公共协议。
7. Report And Redaction
DiagnosticReport 面向人类诊断,不要求 reason 实现 ErrorIdentityProvider。
主入口:
report()into_report()render()render_redacted(...)report_redacted(...)
redaction 适用于 report、protocol projection 和 source frame 诊断视图。机器协议中的 稳定 code/category 不应被当成自然语言 detail 处理。
8. Compatibility Policy
0.8 当前策略:
- 保持主路径稳定。
- 保持 observation surface 可用,但不把它们放进 quick start。
- 保持
dev::*面向测试和迁移验证。 - 不恢复 0.6 / 0.7 legacy API 作为 root 或 prelude 主路径。
- archive 文档保留历史语境,不代表当前推荐用法。
兼容与迁移
API 重命名历史
| 旧名称 | 新名称 | 简介 |
|---|---|---|
into_as(reason, detail) | source_err(reason, detail) | 原始错误包进来成为 source |
wrap_as(reason, detail) | source_err(reason, detail) | 同上,统一入口 |
upcast() | conv_err() | 跨层 reason 转换 |
err_conv() | conv_err() | 同上 |
旧名称不再可用。如果遇到编译错误,直接替换为新名称即可,参数不变。
0.7 → 0.8 迁移
0.8 删除了以下 0.7 的兼容路径:
compat_prelude/compat_traits模块ErrorOwe系列 trait(owe()/owe_source()等)ErrorWith上的want()/attach_context()/with()OperationContext::with_want()
Public Surface Grading
更新时间:2026-04-30
本文档基于当前 orion-error 0.8.x 代码,给公开 surface 做分级整理。
目标不是继续删除 API,而是固定下面四类边界:
- 主路径 API
- 观察面 API
- 测试 / 适配器入口
- 兼容保留 API
如果后续要继续提升到 9+,这份分级表应作为 public API review 的参考基线。
1. 主路径 API
这些 API 构成当前推荐主路径,应长期稳定保留:
-
StructError<R> -
OperationContext::doing(...) -
OperationContext::at(...) -
with_context(...) -
with_source(...) -
StructErrorBuilder::source(...) -
report() -
render() -
identity_snapshot() -
exposure(...) -
source_err(...) -
conv_err() -
cli::print_error(...)
特征:
- README / tutorial / docs 主文档会优先描述它们
- 新业务代码默认优先使用它们
- 不应再为相同任务引入并列“主路径”
2. 观察面 API
这些 API 有明确价值,但更适合诊断、测试、观测、辅助断言:
source_frames()root_cause_frame()source_payload()source_payload_kind()action_main()locator_main()target_path()render_redacted(...)render_user_debug()render_user_debug_redacted(...)
特征:
- 它们不是主传播 / 主构造入口
- 应在文档里明确属于 observation / diagnostics surface
- 不应在 quick start 中抢占主路径叙事位
3. 测试 / 适配器入口
这些 API 主要服务测试、schema 校验、中间层适配或协议拼装:
ErrorProtocolSnapshot::from_report_skeleton(...)dev::prelude::*dev::testing::*interop::*runtime::source::*
特征:
- 允许公开存在
- 但应明确不是正常业务主路径
- 文档中应把它们描述成 secondary path
- 其中
dev::prelude::*应保持在对象级检查面,不再扩成 frame 级宽导出
4. 兼容保留 API
这些字段或投影仍然有现实兼容价值,但名字本身带有历史包袱:
- context / snapshot frame 中的
target
当前统一口径:
- runtime 主语义应优先理解为
action/locator/ path segments target继续存在,主要作为 compat projectionpath是稳定导出的路径投影
5. 当前结论
当前 orion-error 的主要结构问题已经不是“大量兼容 API 混在主路径里”,而是:
- 少量 compat projection 字段仍公开存在
- 少量 observation / secondary path 仍需要靠文档说明降级
这意味着下一阶段如果要继续打磨:
- 不应再优先做内部模型重写
- 应优先做 public surface review 与分级锁定
6. 后续建议
如果进入下一个版本线,可以按这个顺序评估:
- 是否继续保留 frame 中的
target - 是否需要继续缩窄
dev::prelude::* - 是否要给 observation / adapter API 增加更明确的模块或命名提示
在没有明确版本策略前,当前更合理的做法是:
- 保持主路径稳定
- 保持观察面可用
- 用文档和测试锁住 secondary / compat 的定位
Release Checklist
这份清单记录当前 0.8.x 发布时需要执行的步骤。
CHANGELOG.md 只记录版本结果;具体发布动作统一记在这里。
发布前
- 确认
CHANGELOG.md、README.md、docs/已对齐当前代码 - 确认根 crate 和
orion-error-derive的版本号一致 - 运行:
cargo fmt --allcargo clippy --all-targets --all-features -- -D warningscargo test --all-features -- --test-threads=1bash scripts/check-feature-matrix.shbash scripts/check-doc-code.shbash scripts/check-v3-policy.shcargo test --doc --no-default-features
- 在可联网环境中运行:
cargo package --manifest-path orion-error-derive/Cargo.tomlcargo packagecargo publish --manifest-path orion-error-derive/Cargo.toml --dry-runcargo publish --dry-run
发布前边界确认
发布前还应确认下面这些锁没有失效:
src/lib.rs的 root surface compile-fail doctest 仍通过tests/test_layered_exports.rs、tests/test_versioned_namespaces.rs仍覆盖当前分层导出边界- README / tutorial / reason identity guide 的代码块仍与当前源码一致
- 如果 public surface 有新增或迁移:
- 先补测试/compile guard
- 再更新 README / docs
- 最后更新 changelog
正式发布顺序
- 先发布
orion-error-derive - 等 crates.io 索引传播完成
- 再发布
orion-error
当前仓库的 GitHub Actions release workflow 已按这个顺序配置。
发布后检查
- 确认 crates.io 上两个包版本都可见
- 确认
orion-error的默认derivefeature 能正常解析orion-error-derive - 确认 docs.rs 页面生成成功:
orion-errororion-error-derive
StructError 堆分配性能基线
硬件:Apple M4 (Mac mini, 2024)
系统:macOS 15, aarch64
Rust:stable 2025-04-30
运行:cargo test --release --test perf_context_allocation -- --nocapture
测试场景
每个场景重复 500,000 次,测量总耗时后计算均值和吞吐量。
| 场景 | 构造内容 |
|---|---|
bare | StructError::from(UnifiedReason::validation_error()) |
with-detail | 同上 + .with_detail("port number out of range") |
with-detail+pos | 同上 + .with_position("src/config.rs:42") |
builder | builder API 等同 with-detail+pos |
结果
Before:context: Arc<Vec<OperationContext>>
| 场景 | 吞吐量 | ns/iter | 总耗时 |
|---|---|---|---|
| bare | 28 M/s | 35.9 | 17 ms |
| with-detail | 19 M/s | 53.3 | 26 ms |
| with-detail+pos | 15 M/s | 64.6 | 32 ms |
| builder | 15 M/s | 65.1 | 32 ms |
After:context: Option<Arc<Vec<OperationContext>>>
| 场景 | 吞吐量 | ns/iter | 总耗时 | 提升 |
|---|---|---|---|---|
| bare | 55 M/s | 18.2 | 9 ms | +97% |
| with-detail | 27 M/s | 36.6 | 18 ms | +46% |
| with-detail+pos | 20 M/s | 48.9 | 24 ms | +32% |
| builder | 20 M/s | 48.8 | 24 ms | +33% |
优化方法
StructErrorImpl 中的 context: Arc<Vec<OperationContext>> → context: Option<Arc<Vec<OperationContext>>>。
空 context 时不再堆分配,仅在 with_context() 或 ContextAdd::add_context() 首次调用时懒初始化。
分析
- bare(18.2 ns)现为主要来自
Box::new+ 栈构造 - with-detail 比 bare 多一次
String堆分配(约 18 ns) - with-detail+pos 比 bare 多两次
String堆分配(约 30 ns) - 预期符合:去掉一次空 Arc 堆分配 reduce ~18 ns
测试文件:tests/perf_context_allocation.rs
优化改动:src/core/error/carrier.rs + src/core/report/diagnostic.rs
Source Debug 格式化性能影响
测试 eager format!("{source:?}") 在 collect_source_frames 中的实际开销及优化效果。
运行:cargo test --release --test perf_context_allocation -- --nocapture
结果
Before:eager debug: format!("{source:?}")
| 场景 | 吞吐量 | ns/iter | 说明 |
|---|---|---|---|
| bare | 56 M/s | 18.0 | baseline |
| with-std-source | 2.5 M/s | 400.9 | + io::Error |
| with-std-verbose | 1.7 M/s | 581.0 | + 256-byte io::Error |
| with-struct-src | 458 K/s | 2184.8 | + StructError (2 contexts) |
| deep-struct-src | 420 K/s | 2381.9 | + 3 层 StructError 链 |
After:lazy debug: None(优化后)
| 场景 | 吞吐量 | ns/iter | 提升 |
|---|---|---|---|
| bare | 58 M/s | 17.3 | +4% (noise) |
| with-std-source | 3.9 M/s | 259.3 | +55% |
| with-std-verbose | 4.0 M/s | 252.1 | +130% |
| with-struct-src | 849 K/s | 1177.8 | +86% |
| deep-struct-src | 1.2 M/s | 821.3 | +190% |
分析
with-std-source从 400.9 → 259.3 ns,Debug格式化占 ~140nswith-std-verbose从 581.0 → 252.1 ns,长消息的 Debug 开销被完全消除with-struct-src从 2184.8 → 1177.8 ns(-46%),Debug 遍历 context 栈的开销消失deep-struct-src从 2381.9 → 821.3 ns(-65%),最深层的帧直接拷贝已有帧,无额外格式化
优化方法
将 SourceFrame.debug 从 String 改为 Option<String>:
#![allow(unused)]
fn main() {
// Before
pub debug: String,
// 在 collect_source_frames 中:debug: format!("{source:?}"),
// After
pub debug: Option<String>,
// 在 collect_source_frames 中:debug: None,
}
Redaction 仍然支持 debug 字段——测试中显式设置了 Some(...) 的值会被正常处理。None 的帧在 redaction 中跳过。