最近在为 Apache OpenDAL 实现新功能的时候越来越感觉现有的错误处理逻辑能力捉襟见肘,于是花了不少时间重新设计了一套全新的错误处理逻辑。今天这篇周报就跟大家分享一下我在 OpenDAL 中的错误处理实践,希望为大家在 Rust 中构建自己的错误处理体系提供一些思路。
上下文
任何实践都不能脱离具体的场景。
在开始介绍实践之前,先了解一下 OpenDAL 这个项目以及他需要处理的哪些错误。OpenDAL 是一个旨在实现自由、无痛、高效访问数据的 Rust 库,用起来大概是这样:
// Init Operator
let op = Operator::from_env(Scheme::Fs)?;
// Create object handler.
let o = op.object("test_file");
// Read data from object;
let bs = o.read().await?;
作为一个统一的存储抽象,OpenDAL 需要返回统一的 Error 结构体,让用户不需要针对不同的服务来写逻辑。以文件不存在为例,我们需要允许用户写出这样的代码:
if let Err(e) = op.object("test").metadata().await {
if e.kind() == ErrorKind::ObjectNotFound {
// logic if file not exist.
} else {
// logic if we meet other errors.
}
}
也就是说 OpenDAL 要允许用户知道发生了什么错误,使得用户可以根据 Error 的具体类型来分别处理;不仅如此,OpenDAL 对接了海量的存储服务,只返回 Error Code 是远远不够的,OpenDAL 还需要提供足够的上下文,让用户能够根据 Error 快速定位到具体发生了什么。
总结一下 OpenDAL 的需求,它需要一个这样的错误处理框架:
- 知道发生了什么错误
- 能够决定如何处理它
- 辅助定位错误出现的原因
现状
在本次重新设计之前,OpenDAL 使用 std::io::Error 来返回错误。这一设计的好处是最大限度的符合了用户使用 std::fs
的习惯,降低了学习成本。
为了能够返回错误的上下文,OpenDAL 在 io::Error
中携带了 ObjectError
和 BackendError
:
pub struct BackendError {
context: HashMap<String, String>,
source: anyhow::Error,
}
#[derive(Error, Debug)]
#[error("object error: (op: {op}, path: {path}, source: {source})")]
pub struct ObjectError {
op: Operation,
path: String,
source: anyhow::Error,
}
但是在实际运用上,这样的设计非常痛苦。
原始错误泄漏
前面我们总结过,OpenDAL 的一大需求就是提供错误的上下文,因为一个简单的 UnexpectedEof
对用户调试问题没有任何帮助。但是使用 std::io::Error
会导致我们代码中非常容易出现原始错误泄漏的问题,比如:
impl Object {
pub async fn range_read(&self, range: impl RangeBounds<u64>) -> Result<Vec<u8>> {
...
io::copy(s, &mut bs).await?;
Ok(bs.into_inner())
}
}
此处的 io::copy
中出现的 io 错误会在没有任何上下文的情况下被泄漏出去。
错误上下文构建困难
std::io::Error
是一个外部类型,我们能做的事情很少。为了携带 context,我们必须要在构建 Error 时就把所有的上下文传递过去,这让我们实现时不得不写很多重复的逻辑。OpenDAL 中有大量此类帮助函数:
pub fn new_other_object_error(
op: Operation,
path: &str,
source: impl Into<anyhow::Error>,
) -> io::Error {
io::Error::new(io::ErrorKind::Other, ObjectError::new(op, path, source))
}
pub fn new_response_consume_error(op: Operation, path: &str, err: Error) -> Error {
Error::new(
err.kind(),
ObjectError::new(op, path, anyhow!("consuming response: {err:?}")),
)
}
设计目标不一致
std::io::Error
被设计用来返回底层的 IO 错误,它的设计目标与 OpenDAL 的需求是不一致的,用户无法通过 ErrorKind::NotFound
来判断是 Object 不存在还是对应的 Bucket 不存在。不仅如此,std::io::Error
作为标准的一部分,与它相关的改进推动起来都比较困难。时至今日,OpenDAL 一直在等待的 io_error_more feature 还没有被 stable。这意味着,OpenDAL 无法返回 ErrorKind::IsADirectory
和 ErrorKind::NotADirectory
,进而导致用户无法处理相关错误。
设计
综合考虑以上各种因素之后,我决定重新设计 OpenDAL 的 Error 类型。
ErrorKind
ErrorKind 基本上参考 io::ErrorKind
,但是只选取了 OpenDAL 需要使用的部分:
#[non_exhaustive]
pub enum ErrorKind {
Unexpected,
Unsupported,
BackendConfigInvalid,
ObjectNotFound,
ObjectPermissionDenied,
ObjectIsADirectory,
ObjectNotADirectory,
}
ErrorStatus
为了能够更好地支持用户实现错误重试等逻辑(这是 OpenDAL 用户的常见需求),我在 Error 中引入了 ErrorStatus
的概念:
enum ErrorStatus {
Permanent,
Temporary,
Persistent,
}
Permanent
表示错误是永久的,在没有外部变化的情况下,用户不应该重试这个错误Temporary
表示错误是临时的,用户可以尝试重试这个请求来解决它Persistent
表示错误曾经是临时的,但是在重试之后还在持续出错,不鼓励用户再尝试这个请求
Error Operation
在错误定位中最有帮助的就是知道这个错误发生在什么样的操作中,为此 OpenDAL 加入了 Error Operation
:这是一个 &‘static str
,OpenDAL 的实现者可以通过 with_operation()
来追加这一信息:
pub fn with_operation(mut self, operation: &'static str) -> Self {
if !self.operation.is_empty() {
self.context.push(("called", self.operation.to_string()));
}
self.operation = operation;
self
}
特别的,如果这个 Error 过去被设置过 operation
,我们会在 context 中追加一个新的 called
context,来标记这个错误都在哪些操作中调用。
Error Context
OpenDAL 在错误中增加了 Error Context
来携带上下文的相关信息:
pub struct Error {
...
context: Vec<(&'static str, String)>,
}
impl Error {
pub fn with_context(mut self, key: &'static str, value: impl Into<String>) -> Self {
self.context.push((key, value.into()));
self
}
}
一般的,我们会需要支持这个错误来自哪个服务,正在请求哪个路径等。OpenDAL 通过实现了一个 Error Context Wrapper 来为所有的 Service 自动实现这一功能:
pub struct ErrorContextWrapper<T: Accessor + 'static> {
meta: AccessorMetadata,
inner: T,
}
#[async_trait]
impl<T: Accessor + 'static> Accessor for ErrorContextWrapper<T> {
async fn read(&self, path: &str, args: OpRead) -> Result<ObjectReader> {
let br = args.range();
self.inner.read(path, args).await.map_err(|err| {
err.with_operation(Operation::Read.into_static())
.with_context("service", self.meta.scheme())
.with_context("path", path)
.with_context("range", br.to_string())
})
}
}
得益于这一设计,OpenDAL 从 Services 实现中删除了海量 context 相关的代码。
Error Source
显然的,OpenDAL 需要能够把底层的错误也暴露出来。这里 OpenDAL 没有使用 thiserror
来自动为所有的 error 实现 #[from]
,因为对用户来说,没有办法处理的错误是没有意义的。即使 OpenDAL 处理并细化的返回 IoError,XmlError,JsonError,用户也没有办法根据这个错误知道他们应该做什么。OpenDAL 的选择是统一使用 ErrorKind
来返回错误类型,通过 ErrorStatus
来返回错误状态,除此以外,用户只能直接把错误返回给更上层。
OpenDAL 选择使用 anyhow::Error
来携带错误源:
pub struct Error {
...
source: Option<anyhow::Error>,
}
impl Error {
pub fn set_source(mut self, src: impl Into<anyhow::Error>) -> Self {
debug_assert!(self.source.is_none(), "the source error has been set");
self.source = Some(src.into());
self
}
}
开发者可以通过 set_source
来设置错误源,并通过 debug_assert
来防止 source 被多次设置。
这里有一个很容易忽略的点:我们不需要为所有的 Error 生成 backtrace。很多业务上预期的错误,比如 ObjectNotFound
,完全可以不需要携带额外的错误源和 Backtrace 信息。
使用
综合上面的所有特性,我们得到了这样一个 Error:
pub struct Error {
kind: ErrorKind,
message: String,
status: ErrorStatus,
operation: &'static str,
context: Vec<(&'static str, String)>,
source: Option<anyhow::Error>,
}
在 OpenDAL 有这样一些使用原则:
- 所有的函数都应当返回
Result<T, opendal::Error>
- 来自外部库的错误使用
set_source(err)
封装为opendal::Error
并返回 - 谨慎实现
From<OtherError> for opendal::Error
,防止原始错误泄漏 - 同一个错误只处理一次,后续的操作只追加 context,不重复 wrap
OpenDAL 学习自 anyhow
,为 Error 分别实现了 Display
和 Debug
。
其中 Display
会展示紧凑的错误信息,不展示 source,也没有 backtrace:
ObjectNotFound (permanent) at stat, context { service: s3, path: x/x/y } => status code: 404, headers: {"x-amz-request-id": "TTD9EWB9NZ4ZF1DP", "x-amz-id-2": "ch/MHMf/zwPLxWgtBBY7fw9i9K+FGxDRzx3sxrbQKbtl21SONzTpNvs1IrFt2OjhAexcEB3Oo+c=", "c***tent-type": "applicati***/xml", "date": "M***, 21 Nov 2022 15:31:06 GMT", "server": "Amaz***S3"}, body: ""
而 Debug
则会展示完整的错误信息:
Unexpected (temporary) at write => send async request
Context:
called: http_util::Client::send_async
service: s3
path: c40634f8-4a4b-479e-b98f-1ee0d7f1041b
Source: error sending request for url (https://s3.us-west-1.amazonaws.com/***/***84551d50-811f-4614-87cb-ede43447dfbf/c40634f8-4a4b-479e-b98f-1ee0d7f1041b): user body write aborted: early end, expected 2621254 more bytes
Caused by:
0: user body write aborted: early end, expected 2621254 more bytes
1: early end, expected 2621254 more bytes
总结
本文分享了 OpenDAL 的错误实践,基本思想是用户的角度出发区分预期错误和非预期错误。对预期错误,给定明确的错误类型,帮助用户写出清晰的错误处理逻辑;对非预期错误,使用同一个错误类型,比如 Unexpected 来封装,并辅以错误状态帮助用户决策是否进行重试。不要滥用 thiserror
以及 From<OtherError> for Error
等机制,不要盲目的给用户返回大量没有操作空间的错误码。
此外,设计一个良好的错误上下文机制,确保每个错误只处理一次,避免一个错误被反复封装好几层。一方面有额外的性能影响:对 OpenDAL 来说,错误分支可能是少数的 case,但是用户的逻辑中完全有可能重度依赖 OpenDAL 返回的错误;另一方面不利于用户阅读并调试错误:重复的封装让开发者一眼找不到重点,在一层又一层的结构体中迷失了上下文。
总之,一定要从用户体验角度出发设计接口~