凌晨三点,生产环境宕机。你盯着日志行中的错误信息:"序列化错误:第 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返回类型。不要把它看作 "我可能会失败"。把它看作 "我可能需要解释自己"。
如果你的错误类型不能回答 "我应该重试吗?"—— 你让机器失望了。如果你的错误日志不能回答 "是哪个用户?"—— 你让人类失望了。
错误不仅仅是需要传播的故障模式。它们是通信。它们是系统出错时发送的消息。像任何通信一样,它们值得被设计。
停止转发错误。开始设计它们。
资料来源
- FastLabs 博客:"Stop Forwarding Errors, Start Designing Them" - https://fast.github.io/blog/stop-forwarding-errors-start-designing-them
- Zuplo 学习中心:"Best Practices for Consistent API Error Handling" - https://zuplo.com/learning-center/best-practices-for-api-error-handling