Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 边界区分内部诊断报告和对外暴露视图
日志说明说明如何在错误边界输出有效日志,避免到处散落日志代码
生态方案对比对比 anyhowthiserrorcolor-eyreorion-error 的适用边界
与 thiserror 的关系说明两者不是简单替代关系,分别适合不同层级
大型工程错误治理宣言WuKong 模型、治理原则与工业级验证
设计约束说明 orphan rule 等 Rust 语言约束下的 API 取舍

开发文档

文档内容
API Contract0.8 公共 API、分层模块、feature-gated API 和稳定快照契约
兼容与迁移旧 API 到当前 API 的迁移说明
Public Surface Grading公共暴露面的分级评估
Release Checklist发布前检查项
StructError Allocation分配行为与性能记录
StructError Source Debugsource debug 路径的性能记录

当前主路径 API

新代码优先使用:

  • reason.to_err():把单个 reason 转成 StructError
  • result.source_err(reason, detail):让普通错误进入结构化错误系统,或建立新的上层语义边界
  • result.conv_err():对已有 StructError<R1> 做 reason-only 类型转换
  • err.with_source(source) / StructError::builder(reason).source(source):自动识别 raw std error 或下层 StructError source
  • OperationContext::doing(...).with_field(...).with_meta(...):链式携带结构化上下文

稳定外部身份使用 ErrorIdentity.codeErrorCode 是兼容数字码,不应作为主要治理身份。

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 携带 pathcomponent.name 等关键环境信息。

1.2 技术细节错误需要抽象,但不能丢诊断信息

跨层传播技术错误时,常见两个坏选择:

  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_idcomponent.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);
}

这意味着:

  • 业务层不需要到处拼日志字符串。
  • 日志可以统一包含 identityreasondetailcontextsource 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 把“错误内部结构”和“外部呈现”分开:

  • 内部保留 reasonErrorIdentity.codedetailcontextsource chain
  • 面向用户时,只暴露安全、可理解、可行动的信息
  • 面向系统调整者时,暴露组件、操作、分类、重试、严重性等治理信息
  • 面向开发者时,使用 report 查看完整诊断链
  • 面向协议时,使用 exposure 形成稳定字段结构
#![allow(unused)]
fn main() {
let report = err.report();
let exposed = err.exposure(&DefaultExposurePolicy::default());
}

同一个错误对象可以投影成不同用途:

对象需要的信息推荐投影
用户安全 message、可行动提示exposure view
运维 / SREcomponent、operation、retryable、severityexposure snapshot / log JSON
开发者source chain、detail、contextreport
协议客户端stable code、字段结构、retry hintHTTP/RPC/CLI error JSON
测试 / 回归稳定结构快照stable snapshot

这也是 orion-error 与单纯展示层工具的关键区别:它不是只把错误显示得更漂亮,而是保留一个结构化错误,再按边界需求投影不同视图。


总结

orion-error 适合的不是“最少代码返回一个错误”,而是“让错误成为系统契约”。

它解决的核心问题是:

  1. 补齐错误发生环境:底层错误不会自动携带 path、tenant、order_id、operation 等关键上下文。
  2. 抽象技术细节而不丢诊断信息:在层边界把技术失败转换成稳定领域语义,同时保留 source 和 context。
  3. 保留跨层错误传递链:让排错看到失败如何从底层一路被解释成最终边界错误。
  4. 让日志有效而克制:错误对象携带结构化信息,边界统一记录,减少重复日志和业务代码里的日志拼接。
  5. 按对象呈现不同错误视图:同一个错误,面向用户、运维、开发者、协议客户端和日志系统,应有不同粒度和安全等级的输出。

一句话:

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 包含:

  • derive
  • log

导入约定

推荐优先使用下面两种方式:

#![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::* 只导出主路径:OrionErrorStructErrorSourceErrErrorWithConvErr
  • 新业务代码默认先用 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 会为该类型生成:

  • Display
  • DomainReason
  • ErrorCode
  • ErrorIdentityProvider

默认规则:

  • identity = "biz.order_not_found" 生成 stable code
  • category 默认由 identity 前缀推导
  • message 未显式指定时,会从 identity 最后一段推导出显示文案
  • code 未显式指定时,兼容数值码默认是 500

1.2 通用 reason

UnifiedReason 是 crate 内置的通用错误分类,已经实现:

  • DomainReason
  • ErrorCode
  • ErrorIdentityProvider

常用构造:

  • 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(...)action
  • OperationContext::at(...)locator
  • StructError::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::Error
  • anyhow::Error(启用 anyhow feature)
  • serde_json::Error(启用 serde_json feature)
  • toml::de::Error / toml::ser::Error(启用 toml feature)
  • 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 与稳定身份

本文解释当前实现里的四个概念:

  • DomainReason
  • OrionError
  • ErrorCode
  • ErrorIdentityProvider

核心结论先放在前面:

  • 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 会同时生成:

  • Display
  • DomainReason
  • ErrorCode
  • ErrorIdentityProvider

因此它是当前定义领域 reason 的推荐入口。

3. ErrorCodeErrorIdentityProvider 的区别

3.1 ErrorCode

ErrorCode::error_code() 返回兼容数值码:

  • UnifiedReason::system_error()201
  • OrionError 未显式声明 code = ... 时默认是 500

它的定位是:

  • 兼容旧系统
  • 兼容已有数值码测试
  • 保留传统集成接口

3.2 ErrorIdentityProvider

ErrorIdentityProvider 提供两个稳定协议字段:

  • stable_code()
  • error_category()

例如:

  • sys.io_error
  • biz.invalid_request
  • logic.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 控制 Display
  • code 控制兼容数值码
  • identity 控制 stable code
  • category 可显式覆盖,也可由 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 会把以下能力委托给内部单字段类型:

  • Display
  • ErrorCode
  • ErrorIdentityProvider

这适合保留一个 General(UnifiedReason) 兜底通道。

5. 为什么不能只靠 Display

Display 适合给人看,不适合当协议主键。

原因:

  • 文案可能优化
  • 文案可能国际化
  • 文案可能带动态值
  • 不同调用方很难稳定依赖自然语言文本

例如:

  • read config failed
  • failed to read config
  • 读取配置失败

这些都可能描述同一类错误,但它们不适合做稳定协议主键。

相比之下:

  • sys.io_error
  • biz.order_not_found

才适合作为跨边界约定。

6. UnifiedReason 的角色

UnifiedReason 是内置的通用错误分类,已经实现:

  • DomainReason
  • ErrorCode
  • ErrorIdentityProvider

这意味着它可以直接:

  • 作为 StructError<UnifiedReason> 的 reason
  • 作为 #[orion_error(transparent)] 的底层 reason
  • 进入稳定身份和协议投影路径

例如:

  • UnifiedReason::system_error() -> sys.io_error
  • UnifiedReason::network_error() -> sys.network_error
  • UnifiedReason::core_conf() -> conf.core_invalid
  • UnifiedReason::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_request
  • biz.order_not_found
  • conf.feature_invalid
  • sys.io_error

不推荐:

  • read_config_failed_for_user_42
  • primary_db_timeout_us_east_1
  • error_1

协议契约

更新时间:2026-04-23

本文档描述 orion-error 当前已经落地的协议层设计。

这里说的“协议层”不是新的 runtime 传播模型,而是对外稳定消费接口。

1. 三层结构

当前协议层由三层组成:

  1. 稳定身份:ErrorIdentity
  2. exposure 决策:ExposureDecision
  3. 出口投影:HTTP / CLI / log / RPC / user debug

推荐把它理解为:

  • StructError<R> 负责运行时传播
  • ErrorIdentity 负责稳定识别
  • DiagnosticReport 负责人类诊断
  • ErrorProtocolSnapshot 负责把 identity + decision + report 组装成统一消费输入

2. 稳定身份

稳定身份结构是 ErrorIdentity

字段:

  • code
  • category
  • reason
  • detail
  • position
  • path

语义:

  • 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_status
  • visibility
  • default_hints
  • retryable

默认 exposure 策略实现是 protocol::DefaultExposurePolicy

当前默认规则:

  • Biz -> 400 + Public
  • Conf / Logic / Sys -> 500 + Internal
  • sys.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 是当前统一协议输入。

结构:

  • identity
  • decision
  • 内嵌诊断 report,可通过 report() 只读访问

入口:

  • StructError::exposure(...)
  • StructError::into_exposure(...)

适用场景:

  • 测试快照
  • 网关二次投影
  • 协议统一出口
  • 用户调试摘要

5. HTTP Projection

JSON 字段:

  • status
  • code
  • category
  • message
  • visibility
  • hints

规则:

  • Public 时,message 优先使用 detail
  • Internal 时,message 使用稳定 reason

入口(需要 serde_json feature):

  • ErrorProtocolSnapshot::to_http_error_json()

6. CLI Projection

JSON 字段:

  • code
  • category
  • summary
  • detail
  • visibility
  • hints

规则:

  • summary 使用 compact render
  • detail 使用 verbose render

入口(需要 serde_json feature):

  • ErrorProtocolSnapshot::to_cli_error_json()

7. Log Projection

JSON 字段:

  • code
  • category
  • reason
  • detail
  • operation
  • path
  • visibility
  • hints
  • root_metadata
  • context
  • source_frames

规则:

  • 保留完整 context
  • 保留 root_metadata
  • 保留 source_frames

入口(需要 serde_json feature):

  • ErrorProtocolSnapshot::to_log_error_json()

8. RPC Projection

JSON 字段:

  • status
  • code
  • category
  • reason
  • detail
  • visibility
  • hints
  • retryable

规则:

  • 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. 建议的消费路径

推荐顺序:

  1. 运行时传播用 StructError<R>
  2. 要稳定识别时取 identity_snapshot()
  3. 要统一出口规则时取 exposure(...)
  4. 要协议出口时使用 projection API
  5. 要人类摘要时使用 render_user_debug(...)

不建议:

  • 直接把 Display 文本当协议主键
  • 直接把 CLI 文本当机器协议
  • detail 全文本做唯一稳定断言

Report / Exposure 边界

本文档描述 DiagnosticReportErrorProtocolSnapshot 之间的职责边界。

对象分工

对象职责
StructError<R>运行时传播、source 链、上下文挂载
DiagnosticReport人类诊断视图、redaction、文本渲染
ErrorProtocolSnapshotidentity + 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 的日志能力围绕 OperationContextOperationScope 展开。

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_info
  • log_debug
  • log_warn
  • log_error
  • log_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. 定位总览

维度anyhowthiserrorcolor-eyreorion-error
定位快速错误处理标准错误类型 derive诊断式错误报告结构化错误治理框架
目标用户应用开发者(快速原型)库作者应用开发者(诊断)大型多团队工程
问题域减少错误处理样板代码减少 Error impl 样板代码改善错误诊断输出统一错误建模 → 运行时传播 → 边界协议投影
抽象层级类型擦除类型安全 enum类型擦除 + 诊断泛型结构化载体

2. 核心能力对比

错误定义

能力anyhowthiserrorcolor-eyreorion-error
自定义错误类型不直接支持#[derive(Error)]不直接支持#[derive(OrionError)]
泛型错误类型Box<dyn Error>用户定义 enumBox<dyn Error>StructError<T: DomainReason>
稳定 Identitystable_code() + ErrorCategory
数值 ErrorCode#[error(...)] 间接内建 error_code()
Display / source自动自动自动自动(OrionError derive)

运行时传播

能力anyhowthiserrorcolor-eyreorion-error
上下文附着.context(...) / .with_context(...).sections() / .note() / .with_section()OperationContext(doing/at/path + KV + metadata)
Context 路径单层 context 链单层多层嵌套 path 规整:target_path segments
自定义元数据无(仅消息)Section traitErrorMetadata(typed KV,不进入 Display)
Source 链追踪标准链标准链标准链 + SpanTrace双通道(Std/Struct)+ SourceFrame 丰富元数据
跨类型转换anyhow!()#[from]eyre!()source_err(...) / conv_err()

边界输出

能力anyhowthiserrorcolor-eyreorion-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 生态

能力anyhowthiserrorcolor-eyreorion-error
实现 StdError显式 interopas_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 JSONorion-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 tracecolor-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-errorthiserror 不是互斥关系,但定位不同。

定位差异

thiserror:定义标准 Rust error 类型,服务于 std::error::Error 生态。

orion-error:定义运行时结构化错误载体,管理上下文、source frame、快照、协议投影。

能力对比

能力thiserrororion-error
定义标准错误类型不是主要目标
领域 reason derive需要额外补稳定身份OrionError 是推荐入口
运行时结构化上下文
source frame 追踪
stable code / category
snapshot / report / projection

什么场景保留 thiserror

  • 对外公开标准 std::error::Error 类型
  • 外部库 API 要求标准 error 类型

Wukong 错误治理模型:稳定契约、可靠诊断与适配输出

这篇文章讨论一个问题:工业级系统如何把千变万化的错误纳入可治理、可诊断、可演进的结构。

如果只想快速把握主线,可以先读这四段:

  1. 核心矛盾:为什么错误治理的本质是”收敛 vs. 诊断”。
  2. 我们的方案:Wukong 错误治理模型:模型如何用稳定契约、可靠诊断和适配输出治理错误。
  3. 基于 Rust 的错误治理方案:如何用 orion-error 落地。
  4. 工业级应用验证:WarpParse:高吞吐 ETL 场景如何验证这套方法。

本文分三层结构,可按需阅读:


错误处理是原型与工业级的分水岭

原型只需证明正确路径可以跑通;工业级应用还须在非理想条件下可运行、可诊断、可恢复、可演进。

系统不会长期运行在理想条件下。输入变化,依赖退化,网络抖动,配置漂移,数据积累脏状态,业务规则迭代。处理路径随用户、环境、状态和策略动态分叉。正确路径不是系统运行的全部;失败、降级、重试、回滚、补偿和人工介入同样是生命周期的一部分。

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

很多项目在早期把错误处理当作“每个函数自己的事“。每个函数决定如何表达失败,然后这个决策在下一个函数、下一个模块、下一个边界被重新做一次。

这种模式在小型项目中可以工作——调用链短、边界少、参与者对上下文有共同记忆。但当错误信息没有统一形态,且开始跨越团队、子系统、服务边界、协议边界或长期兼容边界时,失败路径就会变得不可治理:

  • 同一种失败,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错误可以是可查询、可自动化处理的系统状态
Terraformdiagnostics 含 severity、summary、detail、attribute path错误应指出位置、原因和修复方向
rustc错误码、源码位置、label、note、help 构成诊断体验诊断信息本身是产品体验
Envoyaccess 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 表达,错误标识本身必须是稳定的、可文档化的、被测试约束的契约。

分类空间天然膨胀。 异常机制鼓励”一种失败一个类”:SubmitDependencyUnavailableExceptionInvalidStateException……类数量跟随业务失败模式无限制增长,没有机制强制收敛到有限分类。如果异常类型同时承担分类职责,分类就不可能稳定——每新增一个 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 中。

reasonidentity 的关系需要更严格地理解:reason 是进程内代码用来表达领域分类的类型,identity 是跨边界、跨版本的错误标识。一个 reason 变体通常提供一个稳定 identity;外部协议、监控和告警应依赖 identity.code,不应依赖 Rust enum 名、Java class 名或 Display 文案。跨语义域传播时,上层不应暴露下层 reason 类型,但可以把下层错误保留在诊断链中。

组成部分含义例子
稳定错误标识机器可判读的错误主键,面向长期兼容order.not_foundsystem.timeout
稳定分类面向治理决策的有限类别业务错误、配置错误、系统错误、超时、限流
治理属性从稳定错误标识和分类派生的辅助决策字段category、retryable、暴露等级、HTTP 状态码
诊断链跨层传播时保留的 cause/source 路径service failure -> repository failure -> database timeout
上下文当前操作的结构化环境,回答”在哪、对谁、执行什么”operation、tenant、path、order_id、component
细节当前层对这次失败的具体解释read config failedupstream 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_foundsystem.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、解析、网络错误)首次进入结构化系统,需同时完成:

  1. 选择分类(业务 vs 系统 vs 配置)
  2. 给出当前层解释(detail)
  3. 保留原始错误作为底层原因

三个诊断概念的分工: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 变更一样被管理:有命名规范、兼容性规则、策略映射、测试覆盖,也有废弃和迁移路径。


不适用场景

  1. 小型项目、原型、脚本。 边界少、生命周期短、错误在局部处理时,没必要引入分层治理。
  2. 性能极端敏感的场景。 结构化错误路径有分配、原因链和上下文采集、序列化等成本;静态类型语言中泛型或模板还可能增加编译时间和代码体积。
  3. 错误不需要跨层传播。 若所有错误都在一层内处理完毕,收益接近于零。

阶段性小结

以上完成了通用方法论:错误治理为什么重要、核心矛盾是什么、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&lt;RepositoryReason&gt;"]
    service["Service 层<br/>StructError&lt;OrderReason&gt;"]
    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_idcomponent.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 的实际采纳并不简单。生态中长期存在 failureerror-chainanyhowthiserroreyre 等不同取向:有的偏快速传播,有的偏诊断报告,有的偏领域错误定义。团队仍需明确边界:哪些层用结构化治理错误,哪些边界允许快速错误聚合,哪些错误标识进入长期契约。

TypeScript — 亲和度高

type AppErrorClass =
  | { kind: "not_found"; id: string }
  | { kind: "system_error" };

Union type + discriminated union 天然适合错误分类。neverthrowfp-tsEither 等库提供了返回值式错误处理。弱点是运行时类型信息有限,跨进程、跨包、跨 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 中 RepositoryReasonOrderReason 是两个独立 enum 同理,Java 中 RepositoryErrorOrderError 是两个独立 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() 决定状态码和响应体。

概念RustJava
语义域分类enum RepositoryReasonsealed class RepositoryError
契约通道reason 变体 + identity 字符串子类覆写的 identity() / category() / retryable()
诊断通道StructError 的 detail / context / sourcegetMessage() / 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类型系统匹配,但错误生态路径较多,需团队约定边界
Swiftenum/Result 表达自然,但 throws 仍是重要生态路径
TypeScript中高discriminated union 方便,但运行时需 schema/tag 补足
C#泛型和中间件成熟,但异常生态主导,DU 需模拟
Javacause 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) 打断。这是它真正优雅的地方。但代价也很明确:语言只给了 throwreturn,没有告诉你线划在哪。用户输入不合法,是抛异常还是返回 error?订单库存不足,算业务逻辑还是异常?同一个函数,不同的人写,对”异常”的定义可能完全不同。分离了路径,但没有告诉我们分离的边界。

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

用一个比喻来说:

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

Rust 的选择是取消特区。 错误就是值——和 i32StringOption<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)。但异常类型受继承层次控制,重构时会变。错误码只是异常的附赠字段。

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

第三,治理和诊断共用同一通道,互相牵制。 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,而非一个全局大枚举。 RepositoryReasonOrderReason 各自在自己的边界内约束分类空间,跨域时显式转换。

规则二:首次进入即结构化。 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 实现)把一致性要求前移,人写的代码和模型生成的代码围绕同一套约束工作。

局限

  1. 前期建模仍需人工。 Reason 分类和 Policy 定义是契约,不能由 AI 生成——它们需要人对业务和架构的判断。
  2. 存量迁移不在 AI 当前能力范围内。 把 L0 的字符串错误重构成结构化体系涉及类型变更和语义判断,AI 只能辅助不能主导。
  3. 领域语义选择仍会出错。 模型可能在“这个失败属于 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 的类型(外来)
  • 即使 LocalReasonLocalReason2 是本地类型

Orphan rule 要求 trait 或 self type 至少有一个本地锚点,但这里 From 是外来 trait,StructError<_> 也是外来类型。 即使 LocalReasonLocalReason2 是本地类型,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 服务中的错误处理,有五个未满足的需求:

  1. 收敛不丢信息。 下层技术错误需要抽象成上层稳定语义,但原始根因(source chain、detail、context)必须保留给排障使用。
  2. 跨层传播。 错误经过多层(handler → service → repository → database),每层都需要附着自己的上下文,但不能丢弃前面的信息。
  3. 边界投影。 同一个错误面向不同对象必须有不同视图:最终用户(安全消息)、运维(组件 + 可重试性)、协议客户端(稳定 code + 结构)、开发者(完整链)。
  4. 可治理的稳定身份。 错误需要稳定、机器可读的 identity,在重构后仍保持不变,贯穿 HTTP/RPC/日志/CLI 边界。
  5. 结构化载体。 错误携带 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 + SyncStructError 需要跨 async 任务边界,能被 anyhow::ErrorBox<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 阻止(FromStructError 都不属于用户 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 可以廉价 clone
  • Box<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 控制哪些错误信息到达外部调用方:

PublicInternal
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-105validation_error, not_found
基础设施200-204system_error, network_error, timeout
配置与外部300-301core_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。这是有意为之:

  1. 防止意外的类型擦除。 如果 StructError 实现了 StdError,调用代码可能通过 .into()Box<dyn Error> 无意中擦除 reason 类型,丢失结构化 identity。
  2. 让边界跨越保持显式。 当需要与 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 自动推导
DomainReasonCarrier 兼容性空的 marker 实现
ErrorCode兼容旧系统的传统数值编码code 属性生成,或默认 500
ErrorIdentityProvider稳定 code + categoryidentitycategory 属性生成

属性

属性是否必需生成
identity = "biz.foo"是(除非 transparentstable_code() 返回 "biz.foo"
category = Biz否(从 identity 前缀推断)error_category() 返回指定分类
transparentidentity 的替代将所有方法委托给内部类型
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::Errorserde_json直接 UnstructuredSource 实现
anyhow::Erroranyhow尝试结构化恢复,失败则退化为未结构化 source
toml::de::Errortoml直接 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),
}

GeneralUvs 更清楚地表达“这是非领域特化错误的兜底“。

消费路径收敛: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过程宏派生(OrionErrorErrorCodeErrorIdentityProvider
logOperationContext 日志方法(ctx.info().debug().warn().error())和 Drop 自动日志
tracingTracing 集成(同时启用时优先使用 tracing 而非 log)
serde核心类型的 Serialize/Deserialize 支持
serde_json协议 JSON 投影方法(to_http_error_json() 等)
anyhowanyhow::Error 互操作(支持结构化 source 恢复)
tomltoml::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::ErrorBox<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 只承诺保留最小主路径入口:

  • StructError
  • OperationContext
  • UnifiedReason
  • derive feature 开启时的 derive 宏:
    • OrionError
    • ErrorCode
    • ErrorIdentityProvider

root 不承诺重新暴露 reason trait、protocol type、report type、 interop type 或测试 helper。它们的正式归属在分层模块中。

ErrorCode 作为 derive 宏名字和兼容数值码能力存在;面向外部协议、日志、快照和 监控的稳定机器主键是 ErrorIdentity.code / stable_code()

2. Prelude

orion_error::prelude::* 是新业务代码的推荐导入入口,当前承诺包含:

  • StructError
  • ErrorWith
  • ConvErr
  • SourceErr
  • UnifiedReason
  • derive feature 开启时的 OrionError

prelude 只放主传播路径需要的最小集合。协议、report、interop 和测试 helper 应从各自分层模块导入。

3. Layered Modules

分层模块是非 root 类型和 trait 的正式归属。

  • runtime 运行时传播载体和上下文:StructErrorStructErrorBuilderOperationContextOperationScopeWithContextErrorMetadata
  • runtime::source source 观察模型:SourceFrameSourcePayloadKindSourcePayloadRef
  • conversion 主路径转换 trait:SourceErrErrorWithConvErrConvStructErrorToStructError
  • reason reason trait、分类和内置 reason:DomainReasonErrorCodeErrorIdentityProviderErrorCategoryUnifiedReasonConfErrReason
  • report 人类诊断与 redaction:DiagnosticReportRedactPolicy
  • protocol 协议/exposure 投影:DefaultExposurePolicyExposurePolicyExposureDecisionErrorProtocolSnapshotVisibility
  • interop 标准错误生态互操作:StdStructRefOwnedStdStructErrorOwnedDynStdStructErrorraw_sourceRawSourceRawStdError
  • cli CLI 输出辅助: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 到已有 StructErrorwith_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:

  • log
  • derive

可选 feature:

  • derive 开启 root derive 宏 re-export,并启用 #[derive(OrionError)] 等宏。
  • log 开启 log 集成和 OperationContext drop 日志路径。
  • tracing 开启 tracing 集成;同时启用 logtracing 时,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 的稳定输入由三部分组成:

  • identity
  • decision
  • embedded DiagnosticReport

稳定承诺:

  • identity.code 是协议、日志、监控、测试断言的稳定机器主键。
  • identity.category 是稳定分类。
  • ExposureDecision 的字段名和含义稳定:http_statusvisibilitydefault_hintsretryable
  • HTTP / CLI / log / RPC projection 的顶层用途稳定。

不承诺:

  • render_user_debug() 的文本格式不是机器协议。
  • JSON 中用于人工排障的 summary / rendered_detail 文本不作为精确稳定 schema。
  • source_framesdebugdisplaytype_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,而是固定下面四类边界:

  1. 主路径 API
  2. 观察面 API
  3. 测试 / 适配器入口
  4. 兼容保留 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 projection
  • path 是稳定导出的路径投影

5. 当前结论

当前 orion-error 的主要结构问题已经不是“大量兼容 API 混在主路径里”,而是:

  • 少量 compat projection 字段仍公开存在
  • 少量 observation / secondary path 仍需要靠文档说明降级

这意味着下一阶段如果要继续打磨:

  • 不应再优先做内部模型重写
  • 应优先做 public surface review 与分级锁定

6. 后续建议

如果进入下一个版本线,可以按这个顺序评估:

  1. 是否继续保留 frame 中的 target
  2. 是否需要继续缩窄 dev::prelude::*
  3. 是否要给 observation / adapter API 增加更明确的模块或命名提示

在没有明确版本策略前,当前更合理的做法是:

  • 保持主路径稳定
  • 保持观察面可用
  • 用文档和测试锁住 secondary / compat 的定位

Release Checklist

这份清单记录当前 0.8.x 发布时需要执行的步骤。

CHANGELOG.md 只记录版本结果;具体发布动作统一记在这里。

发布前

  1. 确认 CHANGELOG.mdREADME.mddocs/ 已对齐当前代码
  2. 确认根 crate 和 orion-error-derive 的版本号一致
  3. 运行:
    • cargo fmt --all
    • cargo clippy --all-targets --all-features -- -D warnings
    • cargo test --all-features -- --test-threads=1
    • bash scripts/check-feature-matrix.sh
    • bash scripts/check-doc-code.sh
    • bash scripts/check-v3-policy.sh
    • cargo test --doc --no-default-features
  4. 在可联网环境中运行:
    • cargo package --manifest-path orion-error-derive/Cargo.toml
    • cargo package
    • cargo publish --manifest-path orion-error-derive/Cargo.toml --dry-run
    • cargo publish --dry-run

发布前边界确认

发布前还应确认下面这些锁没有失效:

  1. src/lib.rs 的 root surface compile-fail doctest 仍通过
  2. tests/test_layered_exports.rstests/test_versioned_namespaces.rs 仍覆盖当前分层导出边界
  3. README / tutorial / reason identity guide 的代码块仍与当前源码一致
  4. 如果 public surface 有新增或迁移:
    • 先补测试/compile guard
    • 再更新 README / docs
    • 最后更新 changelog

正式发布顺序

  1. 先发布 orion-error-derive
  2. 等 crates.io 索引传播完成
  3. 再发布 orion-error

当前仓库的 GitHub Actions release workflow 已按这个顺序配置。

发布后检查

  1. 确认 crates.io 上两个包版本都可见
  2. 确认 orion-error 的默认 derive feature 能正常解析 orion-error-derive
  3. 确认 docs.rs 页面生成成功:
    • orion-error
    • orion-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 次,测量总耗时后计算均值和吞吐量。

场景构造内容
bareStructError::from(UnifiedReason::validation_error())
with-detail同上 + .with_detail("port number out of range")
with-detail+pos同上 + .with_position("src/config.rs:42")
builderbuilder API 等同 with-detail+pos

结果

Before:context: Arc<Vec<OperationContext>>

场景吞吐量ns/iter总耗时
bare28 M/s35.917 ms
with-detail19 M/s53.326 ms
with-detail+pos15 M/s64.632 ms
builder15 M/s65.132 ms

After:context: Option<Arc<Vec<OperationContext>>>

场景吞吐量ns/iter总耗时提升
bare55 M/s18.29 ms+97%
with-detail27 M/s36.618 ms+46%
with-detail+pos20 M/s48.924 ms+32%
builder20 M/s48.824 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说明
bare56 M/s18.0baseline
with-std-source2.5 M/s400.9+ io::Error
with-std-verbose1.7 M/s581.0+ 256-byte io::Error
with-struct-src458 K/s2184.8+ StructError (2 contexts)
deep-struct-src420 K/s2381.9+ 3 层 StructError 链

After:lazy debug: None(优化后)

场景吞吐量ns/iter提升
bare58 M/s17.3+4% (noise)
with-std-source3.9 M/s259.3+55%
with-std-verbose4.0 M/s252.1+130%
with-struct-src849 K/s1177.8+86%
deep-struct-src1.2 M/s821.3+190%

分析

  • with-std-source 从 400.9 → 259.3 ns,Debug 格式化占 ~140ns
  • with-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.debugString 改为 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 中跳过。