Hotdry.
systems-engineering

从错误传播到错误设计:构建可预测的错误处理架构与API设计原则

探讨错误处理从简单的错误转发到系统化错误设计的范式转变,提供为机器和人类两个受众设计的可落地架构原则与API错误处理实践。

凌晨三点,生产环境宕机。你盯着日志行中的错误信息:"序列化错误:第 3 行第 7 列期望 ',' 或 '}'"。你知道 JSON 解析失败了,但你完全不知道为什么在哪里导致了这个问题。是配置加载器?用户 API?还是 webhook 消费者?

这个错误已经成功地在你的系统栈中向上冒泡了 20 层,完美地保留了原始消息,却在传递过程中丢失了所有有意义的信息。我们称之为 "错误处理",但实际上这只是错误转发—— 我们把错误当作烫手山芋,捕获它们,包装它们(也许),然后尽快将它们抛向上层。

错误转发的陷阱:为什么传统方法失败了

在 Rust 生态中,std::error::Error trait 假设错误形成链式结构 —— 每个错误都有一个可选的source()指向底层原因。这对于大多数情况有效,但作为标准库抽象,它过于主观。它排除了错误形成树状结构的情况:具有多个字段失败的验证错误、带有部分结果的超时。这些场景确实存在,而标准 trait 无法表示它们。

anyhow采取了相反的方法:类型擦除。到处使用anyhow::Result<T>并通过?传播。不再需要枚举变体,不再需要#[from]注解。问题是它太方便了。每个?都是一个添加上下文的机会。用户 ID 是什么?我们在调用哪个 API?什么计算失败了?错误对此一无所知。

正如 FastLabs 博客中指出的:"anyhow文档鼓励使用.context()来添加信息。但.context()是可选的 —— 类型系统不要求它。' 我稍后会添加上下文 ' 是最容易对自己说的谎言。稍后意味着永远不会 —— 直到凌晨三点生产环境着火。"

范式转变:为两个受众设计错误

真正的范式转变在于认识到错误有两个不同的受众,每个受众都有不同的需求:

受众 目标 需求
机器 自动化恢复 扁平结构、清晰的错误类型、可预测的代码
人类 调试 丰富的上下文、调用路径、业务级信息

当重试中间件接收到错误时,它不关心你精心嵌套的错误链。它只需要知道:这是可重试的吗? 一个简单的布尔值或枚举变体就足够了。

当你在凌晨三点调试时,你不需要知道在栈深处有一个io::Error。你需要知道:哪个文件、哪个用户、哪个请求、我们试图做什么?

大多数错误处理设计既不为机器优化,也不为人类优化。它们为编译器优化。

为机器设计:扁平、可操作、基于类型的错误

当错误需要以编程方式处理时,复杂性是敌人。你的重试逻辑不想遍历嵌套的错误链来检查特定的变体。它只想问:is_retryable()

Apache OpenDAL 的错误设计提供了一个有效的模式:

pub struct Error {
    kind: ErrorKind,
    message: String,
    status: ErrorStatus,
    operation: &'static str,
    context: Vec<(&'static str, String)>,
    source: Option<anyhow::Error>,
}

pub enum ErrorKind {
    NotFound,
    PermissionDenied,
    RateLimited,
    // ... 按调用者可以做什么来分类
}

pub enum ErrorStatus {
    Permanent,   // 不要重试
    Temporary,   // 安全重试
    Persistent,  // 已重试,仍然失败
}

这个设计实现了清晰的决策制定:

// 调用者可以做出明智的决策
match result {
    Err(e) if e.kind() == ErrorKind::RateLimited && e.is_temporary() => {
        sleep(Duration::from_secs(1)).await;
        retry().await
    }
    Err(e) if e.kind() == ErrorKind::NotFound => {
        create_default().await
    }
    Err(e) => return Err(e),
    Ok(v) => v,
}

关键设计决策:

  • ErrorKind 按响应而非来源分类NotFound意味着 "该事物不存在,不要重试"。RateLimited意味着 "放慢速度再试一次"。调用者不需要知道是 S3 404 还是文件系统 ENOENT—— 他们需要知道如何处理它。
  • ErrorStatus 是显式的:不是从错误类型猜测可重试性,而是一个首类字段。服务可以在知道重试可能有帮助时将错误标记为临时错误。
  • 每个库一个错误类型:不是在模块间散布错误枚举,而是使用单个扁平结构保持简单。context字段提供了你需要的所有特异性,而不会导致类型扩散。

为人类设计:低摩擦的上下文捕获

良好错误上下文的最大敌人不是能力 —— 而是摩擦。如果添加上下文很烦人,开发人员就不会去做。

exn库(294 行 Rust 代码,零依赖)展示了一种方法:错误形成帧树,每个帧通过#[track_caller]自动捕获其源位置。与线性错误链不同,树可以表示多个原因 —— 当并行操作失败或验证产生多个错误时很有用。

我们需要的是:

  • 自动位置捕获:不使用昂贵的堆栈跟踪,而是使用#[track_caller]零成本捕获文件 / 行 / 列。每个错误帧都应该知道它是在哪里创建的。
  • 符合人体工程学的上下文添加:添加上下文的 API 应该如此自然,以至于添加它感觉不对。

类型系统成为你的盟友:它不会让你在模块边界偷懒。如果函数返回Result<T, Exn<ServiceError>>,你不能直接?一个Result<U, Exn<DatabaseError>>—— 类型不匹配。编译器强制你调用or_raise()并提供ServiceError,这正是你应该添加上下文关于模块试图做什么的时刻。

API 错误处理的设计原则

对于 API 设计,RFC 9457 Problem Details 规范提供了现代错误报告的标准。这个规范是流行的 RFC 7807 草案的继承者。

问题详情响应作为 JSON 主体返回,具有以下属性:

  • type(字符串,URI):标识特定错误类型的 URI。这有助于客户端理解错误,并可能找到更多信息或文档。
  • title(字符串):问题的简短、人类可读摘要。这应该是简洁传达错误的简要描述。
  • status(整数,可选):源服务器为此问题发生生成的 HTTP 状态代码。
  • detail(字符串,可选):问题更详细、人类可读的解释。
  • instance(字符串,URI,可选):标识问题特定发生的 URI。

标准化错误响应示例:

{
  "type": "https://api.example.com/errors/invalid-input",
  "title": "Invalid Input Parameters",
  "status": 422,
  "detail": "Email address must use user@domain.com format",
  "instance": "/transactions/auth/2024-02-10/125"
}

HTTP 状态码的准确使用

HTTP 状态码是传达错误的第一个工具。关键是准确使用它们,而不是依赖通用代码。

状态范围 目的 常见示例
400-499 客户端错误 401 未授权,422 不可处理实体
500-599 服务器错误 500 内部错误,503 服务不可用

协议特定的错误处理实践

不同的 API 协议有不同的错误处理方法:

REST API:依赖 HTTP 状态码与结构化错误负载配对。RFC 9457 Problem Details 确保跨端点的一致格式。

GraphQL:总是以200 OK状态代码响应,即使发生错误。错误通过errors数组传达,允许部分成功。

gRPC:使用预定义的数字状态代码集(范围 0-16)进行错误处理,与grpc.status对齐。错误包括结构化的详细信息以提供更好的上下文。

可落地的错误处理架构清单

1. 错误分类设计

  • 响应而非来源分类错误(NotFound vs DatabaseError)
  • 为机器恢复定义明确的错误状态(Permanent/Temporary/Persistent)
  • 为人类调试添加上下文字段(用户 ID、请求 ID、操作名称)

2. 上下文捕获机制

  • 实现自动位置捕获(使用#[track_caller]或等效机制)
  • 设计低摩擦的上下文添加 API
  • 在模块边界强制添加上下文(类型系统强制执行)

3. API 错误标准化

  • 采用 RFC 9457 Problem Details 格式
  • 准确映射 HTTP 状态码
  • 为不同协议实现一致的错误结构

4. 安全考虑

  • 在错误消息中清理敏感数据
  • 标准化身份验证错误以防止信息泄露
  • 避免在错误响应中暴露内部系统细节

5. 监控和测试

  • 建立错误代码集中注册表
  • 监控关键指标:MTTA(平均确认时间)、错误复发率
  • 实现自动化错误测试和模式验证

从转发到设计的转变

下一次你写函数时,看看Result返回类型。不要把它看作 "我可能会失败"。把它看作 "我可能需要解释自己"。

如果你的错误类型不能回答 "我应该重试吗?"—— 你让机器失望了。如果你的错误日志不能回答 "是哪个用户?"—— 你让人类失望了。

错误不仅仅是需要传播的故障模式。它们是通信。它们是系统出错时发送的消息。像任何通信一样,它们值得被设计。

停止转发错误。开始设计它们。

资料来源

  1. FastLabs 博客:"Stop Forwarding Errors, Start Designing Them" - https://fast.github.io/blog/stop-forwarding-errors-start-designing-them
  2. Zuplo 学习中心:"Best Practices for Consistent API Error Handling" - https://zuplo.com/learning-center/best-practices-for-api-error-handling
查看归档