最近在为 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 中携带了 ObjectErrorBackendError

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::IsADirectoryErrorKind::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 分别实现了 DisplayDebug

其中 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 返回的错误;另一方面不利于用户阅读并调试错误:重复的封装让开发者一眼找不到重点,在一层又一层的结构体中迷失了上下文。

总之,一定要从用户体验角度出发设计接口~