Error trait 与生态

11 分钟
本页目录

错误设计先回答两个问题:调用者需要根据什么采取行动,排查者需要什么信息理解失败。具体枚举适合表达可匹配的类别;上下文说明当时在处理哪个业务步骤。两者可以共存,不必把“可恢复”和“可报告”拆成互斥选择。

Error 提供共同的错误接口

std::error::Error 要求 Debug 和 Display,可以通过 source() 暴露导致当前失败的另一个错误。Display 面向可读描述,Debug 面向调试表示;source 是结构化原因关系,不是把多个字符串拼成一段文字。Error

应用上下文启动服务时读取监听配置失败1领域错误 PortError端口文本不能解析为 u162底层 ParseIntError整数解析器给出的具体原因3错误原因链与调用栈是两种信息source 串联原因;backtrace 记录捕获时的调用栈,不能从原因链还原。

错误原因链可能只有一层,也可能跨越解析、存储与应用层。backtrace 则需要在某个时间点采集栈信息;遍历 source 不会自动推导出函数调用栈。即使有栈,采集位置也未必是故障最初发生的位置。

不用派生宏,也能完整实现原因链

先看标准库版本,可以看清 thiserror 后面究竟帮忙生成了什么。字段名标识失败配置,source 保留原始解析错误,Display 只负责当前层的说明。

use std::{error::Error, fmt, num::ParseIntError};
#[derive(Debug)]
struct FieldError {
    field: &'static str,
    source: ParseIntError,
}
impl fmt::Display for FieldError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "配置字段 {} 不是有效的 u16", self.field)
    }
}
impl Error for FieldError {
    fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&self.source) }
}
fn parse(field: &'static str, text: &str) -> Result<u16, FieldError> {
    text.parse().map_err(|source| FieldError { field, source })
}
fn main() {
    let error = parse("port", "bad").unwrap_err();
    assert_eq!(error.field, "port");
    assert!(error.to_string().contains("port"));
    let source = error.source().unwrap();
    assert!(source.downcast_ref::<ParseIntError>().is_some());
    assert!(source.source().is_none());
}

source 返回借用,引用不能超过当前错误对象;返回类型中的 'static 约束针对被擦除的原因类型,并不是把这条引用延长成静态引用。Error trait 自身也不要求所有实现都必须 Send、Sync 或 'static,这些往往是线程间传播和具体错误容器另外施加的要求。

上层 Display 若已经把全部 source 文案嵌进去,报告器再逐层打印时就可能重复同一原因。可以让每层只描述新增上下文,再由统一报告器展开原因链;如果接口选择单行完整展示,也应保持整体策略一致。

包装之后,原因类型还在吗?

同一条可读报错可能对应两种完全不同的数据结构:一条是仍保留 ParseIntError 的原因链,另一条只剩 String。选择内外层,观察普通 dyn Error 的向下转换检查的是当前对象。报告文字、原因关系和捕获的调用栈不能互相代替。

正在呈现知识画面
包装之后,原因类型还在吗?

source 保存可遍历的原因对象;to_string 只保留展示结果,不保留原原因类型。

完整例子:类型用于决策,上下文用于定位

Cargo.toml:

[package]
name = "error-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
thiserror = "2"
anyhow = "1"

src/main.rs:

use anyhow::Context;
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum PortError {
    #[error("port is not a valid u16")]
    Invalid(#[from] ParseIntError),
    #[error("port zero is disabled by this application")]
    Zero,
}

fn parse_port(text: &str) -> Result<u16, PortError> {
    let port = text.parse::<u16>()?;
    if port == 0 {
        return Err(PortError::Zero);
    }
    Ok(port)
}

fn startup(text: &str) -> anyhow::Result<u16> {
    parse_port(text).context("cannot configure listening port")
}

fn main() {
    assert_eq!(startup("8080").unwrap(), 8080);
    assert!(matches!(parse_port("0"), Err(PortError::Zero)));
    assert!(matches!(parse_port("70000"), Err(PortError::Invalid(_))));

    let error = startup("bad").unwrap_err();
    assert!(matches!(error.downcast_ref::<PortError>(), Some(PortError::Invalid(_))));
    for (level, cause) in error.chain().enumerate() {
        eprintln!("{level}: {cause}");
    }
    assert_eq!(error.chain().count(), 3);
}

这里禁止端口 0 是应用策略,不是 u16 或网络协议的规则。底层解析失败经 #[from] 转为 Invalid;业务拒绝则直接构造 Zero。应用外层添加启动上下文后,仍可通过 downcast_ref 找到 PortError。

thiserror 生成普通 trait 实现

thiserror 帮助生成 Display、Error,以及指定的 From 实现,公开的错误仍然是自己的类型。#[from] 同时把该字段作为 source;由于 From 只接收源错误,变体不能还要求一个任意的路径或业务 ID 字段(文档另允许相应 backtrace 字段)。thiserror

如果错误需要附带路径,应在调用位置用 map_err 构造含 path 与 source 的变体,把原错误保留为 source,而不是只存 error.to_string()。前者保留原因类型,后者只剩展示文字。

#[error(transparent)] 用于把展示与 source 行为透明转交给内部错误,不会凭空添加一层业务说明。需要说明“读取哪个配置失败”时,应显式建模这层上下文。

同一个底层错误,可能来自不同业务步骤

From 只能根据传入的错误值转换,无法知道是在读配置还是写检查点。若多个步骤都产生 io::Error,应在发生位置添加操作上下文,避免写两个同源 #[from] 变体而产生冲突。

以下示例使用上面的 thiserror 依赖;通过注入一个失败操作展示构造方式,无需实际读写文件。

use std::{error::Error as _, io};
use thiserror::Error;
#[derive(Debug, Error)]
#[error("操作 {operation} 失败,资源 {resource}")]
struct OperationError {
    operation: &'static str,
    resource: String,
    source: io::Error,
}
fn perform(resource: &str, action: impl FnOnce() -> io::Result<()>)
    -> Result<(), OperationError>
{
    action().map_err(|source| OperationError {
        operation: "load-config", resource: resource.to_owned(), source,
    })
}
fn main() {
    let error = perform("service-config", || {
        Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
    }).unwrap_err();
    assert_eq!(error.operation, "load-config");
    assert_eq!(error.source.kind(), io::ErrorKind::PermissionDenied);
    assert!(error.source().unwrap().downcast_ref::<io::Error>().is_some());
    assert!(perform("service-config", || Ok(())).is_ok());
}

此处 resource 保存适合报告的资源标识,不必包含完整输入内容。#[source] 保留错误原因,map_err 则在调用点填入 From 不具备的上下文。错误包装层数应对应有价值的新信息,而不是每经过一个辅助函数就套一层同义说明。

anyhow 包装错误,但不替你定义恢复策略

anyhow::Error 可以接纳满足 Error + Send + Sync + 'static 的具体错误。context 添加说明,with_context 允许在失败时才构造较昂贵的上下文;对于保留在包装中的错误,仍可做类型向下转换。anyhow

把所有错误改成字符串,再根据字符串包含 timeout 决定重试,会把业务行为绑定到不稳定文案。需要区分限流、认证失败、暂时不可用时,即使在应用内部,也应保留具体类别或稳定的错误码。

anyhow 不会自动捕获 panic,不会自动写日志,也不会撤销副作用。它使错误传播和报告更方便;恢复、重试与事务仍是程序自己的策略。

上下文既可读,也可以是可识别的类型

下面仍使用同一 Cargo 依赖。上下文 LoadingConfig 负责标识业务步骤,底层 ParseIntError 保留具体失败,两者都能被 anyhow 的 downcast_ref 找到。

use anyhow::Context;
use std::{fmt, num::ParseIntError};
#[derive(Debug)]
struct LoadingConfig;
impl fmt::Display for LoadingConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("加载监听配置失败")
    }
}
fn main() {
    let error = "bad".parse::<u16>().context(LoadingConfig).unwrap_err();
    assert!(error.downcast_ref::<LoadingConfig>().is_some());
    assert!(error.downcast_ref::<ParseIntError>().is_some());
    assert_eq!(error.to_string(), "加载监听配置失败");
    assert_eq!(error.chain().count(), 2);
}

这里使用的是 anyhow 对其上下文包装的专门支持;不能推断任意 Box<dyn Error> 的 downcast 都自动遍历 source 链。标准 trait 对象上的 downcast 检查当前对象的具体类型,需要查原因时应显式沿 source 前进。anyhow Context 与 downcast

'static 约束也不意味着错误必须泄漏或活到进程退出。持有 String 的错误可以满足 'static 并很快被释放;持有临时输入 &str 的错误则可能无法放进要求 'static 的容器。需要跨任务保留定位信息时,可保存拥有的简短标识、范围或行号。

原因链、上下文、backtrace 分工

信息回答的问题典型内容
错误类别调用者下一步能做什么无权限、非法配置、暂时不可用
业务上下文当时在执行哪项操作为某个任务加载配置
source 原因链这一层为什么失败解析失败源于无效数字
backtrace捕获时经过哪些调用函数和位置,受符号信息影响

anyhow 的栈回溯支持受构建与运行配置影响。在通常的 std 构建中,可用 RUST_LIB_BACKTRACE=1 请求错误回溯;RUST_BACKTRACE 还涉及 panic 回溯。不要把“有 source 链”当作“必定有完整栈”。

结构化错误 + 原因 + 上下文保留可供程序判断的类别调用方决策停止、修正输入、有限重试内部诊断操作标识、原因链、关联 ID对外响应稳定错误码与可操作说明一份内部错误,在不同边界提供不同的信息错误文案可以调整;恢复逻辑不应依赖字符串匹配,内部原因也不必全部对外公开。

从错误类别到动作,还差一次业务判断

类别可能的动作仍需确认的条件
配置语法错误指出字段并停止启动不应用默认值掩盖显式错误
权限不足修正权限或身份重试同样请求通常不会改变权限
请求超时查询结果或有限重试原操作可能已经成功,需考虑幂等性
限流按服务指引等待重试预算、截止时间与请求有效性
资源不存在返回空结果或报告失败该资源是否本来就是可选项

Error 不定义 is_retryable,原因是“能否重试”通常依赖操作语义。即使错误来自暂时的网络失败,转账请求也不能仅凭一个 transient 标记重复发送;若响应丢失,服务端可能已经执行成功。类型提供决策依据,重试协议仍需包含次数、时间和幂等约束。

在接口边界做取舍

库提供可匹配的错误类型通常有利于调用者恢复,thiserror 能减少实现代码;应用顶层统一携带上下文通常适合 anyhow。它们是常用分工,不是“库必须用某 crate、应用不需要区分错误”的规则。

公共错误枚举的变体也是兼容性设计的一部分,可按扩展需要考虑 non_exhaustive。错误消息应足够定位问题,但避免原样记录密码或完整凭据。通常在真正处理或报告错误的边界记录一次,避免每传播一层就重复输出相同失败。

如果公共枚举将第三方解析器错误直接暴露为变体字段,调用者可能开始匹配那个具体类型,升级依赖也会牵动 API。可以按需要提供稳定的类别访问器,同时把实现细节保留在私有表示中;代价是调用者能直接依赖的信息减少。non_exhaustive 只影响外部穷尽匹配,并不会自动稳定字段类型、错误码或恢复语义。

对外 API 可以返回稳定 code、简短 message 和关联标识,内部日志保留更详细原因;不要把 Debug 或完整 source 链直接当成长期兼容的响应格式。测试可断言变体、关键字段、source 类型以及必要上下文,避免把第三方完整英文报错写成必须永远相同的契约。

先掌握 Result 与 Option 的传播语义,再决定错误类型和报告方式,可以避免把 ? 误解成自动恢复或日志机制。