Axum 设计与实现

第12章 错误处理模型:Infallible 与 HandleError

作者 杨艺韬 · 11,476 字 · 发布于 · 更新于

第 5 章讲 Handler trait 时给出过一个关键断言:"HandlerService 的 Error = Infallible——不可能出错"。第 9-11 章讲响应构造时也反复强调"axum 的响应构造不会失败,失败都转成 Response"。这些机制合起来描绘了 axum 的第一层错误处理模型:handler 代码里的错误被 Result<T, E: IntoResponse> + ? 自然消化;框架层的 Service 错误类型统一是 Infallible。

但这个模型有个边界——Tower 中间件。Axum 的 Router 可以挂任意 Tower middleware 做请求变换、限流、超时、重试、分流。这些中间件的 Service 通常有真实的错误类型(多为 BoxError,也可能是自定义类型),和 axum 要求的 Infallible 不兼容。这就是 HandleError 存在的原因——它是 axum 和 Tower 生态的"阻抗匹配器",把可失败 Service 的 Error 翻译成 Response,让整条链的 Error 类型重新收敛到 Infallible。

本章拆解这个机制的所有细节。

为什么 axum 坚持 Infallible

先回顾 axum 官方对错误模型的立场(axum/src/docs/error_handling.md 开头):

axum is based on tower::Service which bundles errors through its associated Error type. If you have a Service that produces an error and that error makes it all the way up to hyper, the connection will be terminated without sending a response.

关键点:hyper 收到 Service 返回的 Err 时不会替你发 500——HTTP/1 下直接关闭这条连接,HTTP/2 下 reset 这个流,都不发任何 HTTP 响应。客户端收到的不是 "500 Internal Server Error",而是 "Connection closed" / 流被重置——对用户和运维都极不友好。客户端可能看到浏览器错误页、重试逻辑可能把失败当成偶发网络问题、日志里看不到具体 handler 发生了什么。

Axum 的设计哲学因此而定:任何"错误"都应该变成 Response,连接不能静默断掉。类型系统把这条规则强制化——所有 Service 的 Error = Infallible。Infallible 是 std::convert::Infallible——没有变体的 enum,不可能构造出值。Result<T, Infallible> 在类型层保证只能是 Ok(T)——Err 分支永远不可达。

flowchart LR
    classDef ok fill:#efe,stroke:#080
    classDef err fill:#fee,stroke:#c00
    subgraph axum理想
        H1["handler"] --> R1["Result&lt;T, E: IntoResponse&gt;"]
        R1 -->|"Ok or Err 都是<br/>响应"| RES1["Response"]:::ok
        RES1 --> HY1["hyper 写到网络"]:::ok
    end
    subgraph 没有 HandleError 的问题
        H2["handler + Tower 中间件"] --> R2["Result&lt;T, TowerError&gt;"]
        R2 -->|"Err 没转成响应"| FAIL["Err"]:::err
        FAIL -->|"通过到 hyper"| HY2["hyper 关连接 / reset 流"]:::err
        HY2 --> CLIENT["客户端: 连接断开或流被重置"]:::err
    end
    subgraph 有 HandleError
        H3["handler + middleware + HandleError"] --> R3["Result&lt;T, TowerError&gt;"]
        R3 -->|"HandleError 抓到 Err"| F["closure 把 TowerError 转 Response"]
        F --> RES3["Response"]:::ok
        RES3 --> HY3["hyper 写到网络"]:::ok
    end

这张图说明了三种场景:纯 handler + Result 已能工作;混入 Tower 中间件后如果不处理会断连(HTTP/2 下是流被重置);HandleError 是让后者回到前者的桥梁。

Infallible 的类型级保证

Infallible 不是"可能失败但我们期望不失败"——它在类型系统上根本不能构造。写法上:

async fn try_match() {
    let r: Result<&str, Infallible> = Ok("hello");
    match r {
        Ok(s) => println!("{s}"),
        Err(never) => match never {},  // match 空 enum 在任意分支上穷尽
    }
}

match never {} 是合法的——因为 Infallible 没有 variant,编译器知道分支数为 0,空 match 自动穷尽。优化器会直接消除这条分支——运行时零开销。

Axum 依赖这个机制:在代码里看到 Result<_, Infallible> 就当作 _ 处理、Err 分支完全消除。HandlerService::call 的 future.map(Ok as _) 把 Response 包进 Result<Response, Infallible>,外部拿到 Ok(response) 就直接用——编译器优化掉了 Err 检查。

never type 与 Infallible 的关系

Rust 有一个 ! 类型("never")表示"永不返回"——panic! / loop {} / std::process::exit 的返回类型都是 !。Infallible 在概念上等价于 !——都是"不可能有值"的类型。

为什么标准库用 Infallible 而不是直接 !?因为 ! 作为类型参数在 stable Rust 上还不能用——它还是个实验性的 never type feature。Infallible 是一个零 variant 的 enum、stable、可以在 trait bound 里用:

pub enum Infallible {}  // 标准库定义
// 编译上等价于 !,但在稳定 Rust 可用

! 到 Infallible 的历史计划:稳定后 Infallible 会变成 ! 的别名(RFC 1216)。但这个迁移持续好几年还没完成——现实里仍然写 Infallible。对 axum 用户来说不用关心这个差异——只需要知道 Result<T, Infallible> 等价于 T。

为什么不让 Router 直接接受可失败 Service

自然的一个问题:为什么不让 Router::route_service 接受 Service with any Error、内部自动处理?axum 选择显式 HandleError 而不是隐式处理,理由有三:

一、错误到响应的映射是业务决策。同一个 Elapsed 错误,A 服务想映射到 504、B 服务想映射到 503、C 服务想 log 后返 500——框架无法预设正确的映射。显式 closure 把决策留给用户。

二、语义透明。Router 的类型签名里没有 error handling 信息就容易给用户"不会失败"的错觉——但实际挂了 Timeout 就可能失败。显式 HandleErrorLayer 让错误处理在代码里可见、可审查。

三、避免"吞错误"的默认行为。如果框架默认 Err(e) → 500 Internal Server Error,用户可能忘记改定制响应——生产时才发现"系统都返 500 没细分"。强制显式挂 HandleError 迫使用户主动思考。

这种"把决策留给用户、强制显式"的设计风格在 axum 各处都有——不默认、不假设、要求用户明白自己在做什么。初学的学习成本高、但生产可维护性好。

哪些 Service 真的会失败

既然 axum handler 都是 Infallible,哪里会出现 Err?答案是Tower 生态的通用中间件。列出几种典型:

中间件 Service::Error 典型错误值
tower::timeout::Timeout BoxError tower::timeout::error::Elapsed(超时)
tower::load_shed::LoadShed BoxError tower::load_shed::error::Overloaded(内层没 ready 就丢请求)
tower::buffer::Buffer BoxError tower::buffer::error::Closed(worker 已关闭)

反过来,有两个常被误列进这张表的中间件其实不引入新的 Service 错误:tower::limit::ConcurrencyLimit 的 type Error = S::Error(tower 0.5.3 tower/src/limit/concurrency/service.rs),它只是在 poll_ready 里等一个 permit;tower_http::limit::RequestBodyLimit 的 type Error = S::Error(tower-http 0.6.8 src/limit/service.rs),Content-Length 超限时它直接返回 413 响应,否则把 body 换成 Limited——LengthLimitError 是轮询 body 时冒出来的 body 错误,不是 Service::Error。

这些中间件设计之初不是为了专门配合 axum——它们是 Tower 的通用组件,可能用在 HTTP 客户端、gRPC、甚至非 HTTP 的 Service 上。它们的错误类型反映"Tower 层面的失败语义"——并发超限、超时、buffer 关闭等。

把这类中间件直接 .layer(TimeoutLayer::new(...)) 挂到 axum Router 上会编译失败——Router 要求被包装的 Service 最终收敛到 Error = Infallible,Timeout 让类型变成 Error = BoxError,不匹配。

本章所有 TimeoutLayer 都指 tower::timeout::TimeoutLayer。tower-http 也有一个同名的 tower_http::timeout::TimeoutLayer,它超时时自己返回一个响应、不改变 Error 类型,因此不需要 HandleErrorLayer;它的 ::new 自 tower-http 0.6.7 起已弃用,应改用 with_status_code(tower-http 0.6.8 src/timeout/service.rs)。两者别混用。

// ❌ 编译错误:TimeoutLayer 让 Error 变成 BoxError, 不是 Infallible
use tower::timeout::TimeoutLayer;

Router::new()
    .route("/slow", get(slow_handler))
    .layer(TimeoutLayer::new(Duration::from_secs(5)));

编译器报的是 E0277:the trait bound `Infallible: From<Box<(dyn std::error::Error + Send + Sync + 'static)>>` is not satisfied,指向 Router::layer 的 Error: Into<Infallible> 约束(axum/src/routing/mod.rs:301;axum 0.8.9 实测)。必须用 HandleErrorLayer 把 Timeout 的错误先转回 Infallible:

// ✅ HandleErrorLayer 把 BoxError 转成 Response, Error 回到 Infallible
Router::new()
    .route("/slow", get(slow_handler))
    .layer(
        ServiceBuilder::new()
            .layer(HandleErrorLayer::new(|err: BoxError| async move {
                if err.is::<Elapsed>() {
                    (StatusCode::REQUEST_TIMEOUT, "request timed out").into_response()
                } else {
                    (StatusCode::INTERNAL_SERVER_ERROR, "unknown error").into_response()
                }
            }))
            .layer(TimeoutLayer::new(Duration::from_secs(5)))
    );

注意 Layer 的顺序:HandleErrorLayer 必须在 Timeout 之外——它接收 Timeout 可能产生的错误。Tower 的 ServiceBuilder 按声明顺序从外到内包装——先声明的 Layer 在最外层,最先看到请求、最后看到响应。

HandleError 的源码拆解

axum/src/error_handling/mod.rs:115-149 定义了 HandleError 的 Service impl(无提取器参数版本):

// axum/src/error_handling/mod.rs:115-149
impl<S, F, B, Fut, Res> Service<Request<B>> for HandleError<S, F, ()>
where
    S: Service<Request<B>> + Clone + Send + 'static,
    S::Response: IntoResponse + Send,
    S::Error: Send,
    S::Future: Send,
    F: FnOnce(S::Error) -> Fut + Clone + Send + 'static,
    Fut: Future<Output = Res> + Send,
    Res: IntoResponse,
    B: Send + 'static,
{
    type Response = Response;
    type Error = Infallible;
    type Future = future::HandleErrorFuture;

    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }

    fn call(&mut self, req: Request<B>) -> Self::Future {
        let f = self.f.clone();
        let clone = self.inner.clone();
        let inner = std::mem::replace(&mut self.inner, clone);

        let future = Box::pin(async move {
            match inner.oneshot(req).await {
                Ok(res) => Ok(res.into_response()),
                Err(err) => Ok(f(err).await.into_response()),
            }
        });

        future::HandleErrorFuture { future }
    }
}

逐段看:

type Error = Infallible:收敛点

type Error = Infallible——这是整个 HandleError 存在的理由。接收任意 S::Error 的 Service、输出 Error = Infallible。这一步把"可失败 Service"转化成"符合 axum 契约的 Infallible Service"。

call 方法的结构

一、f.clone() + inner.clone() + std::mem::replace:这是 Tower 里的典型模式——Service::call(&mut self, req) 按 &mut 接收、但 future 要 'static。通过先 clone 出一份 inner、再用 mem::replace 把 self.inner 换成 clone,原来的 inner 被 move 进异步块里——保证 future 拥有自己的 inner 副本。

为什么不直接 self.inner.clone()?因为 Tower 惯例 Service 的状态在 call 之间可能改变——poll_ready 返回 Ready 后 call 消费那个 readiness(类似 "token")。直接 clone 可能复制未 ready 的版本。先 replace、再 move 是保持 Tower 语义的安全做法。不过要注意,HandleError::poll_ready 自己直接返回 Ready(Ok(())),并不驱动 inner——真正的 poll_ready 由 future 里的 oneshot 完成,所以在 HandleError 这里 replace 只是沿用惯用写法,不是必需。

二、inner.oneshot(req).await:tower::ServiceExt::oneshot 把 Service + Request 组合成一次性调用——先 poll_ready 等 ready,然后 call。oneshot 是 Tower 生态里"我就想调一次"的规范形态。

三、match 分发:

match inner.oneshot(req).await {
    Ok(res) => Ok(res.into_response()),            // 成功: 转 Response
    Err(err) => Ok(f(err).await.into_response()),  // 失败: 调 closure, 转 Response
}

两个分支都返回 Ok(Response)——外层永远是 Ok、Error 类型是 Infallible。失败走 closure、成功走 IntoResponse——两条路径最终都产生 Response。

四、f(err).await.into_response():closure f: FnOnce(S::Error) -> Fut——接收一个错误、返回一个 Future<Output = Res>、Res: IntoResponse。await 后 into_response 把 closure 产物变成 Response。

closure 是 async 的——允许错误处理本身有 IO(比如查日志服务的 request id、调用 reporting 接口)。但注意:不要在这个 closure 里做慢操作——它发生在响应路径上,延迟会传给客户端。日志类操作要 fire-and-forget:tokio::spawn(async move { report(err).await });。

mem::replace 的 Service 状态迁移

HandleError::call 里的这段:

let clone = self.inner.clone();
let inner = std::mem::replace(&mut self.inner, clone);

比单纯的 self.inner.clone() 多绕一步。原因是 Tower 的 "poll_ready / call" 契约:

  • poll_ready(&mut self, cx) 申请 ready——如果 Service 需要资源(connection slot、concurrency permit),在这里拿到并把状态记在 self 上
  • call(&mut self, req) 消费 ready 产生 Future——Service 把之前申请的资源交给这个 Future

关键是 call 消费 ready——消费之后 self 回到"未 ready"状态,下次用前要再 poll_ready。这意味着在一个 poll_ready 里驱动过 inner 的 Service 中,&mut self.inner 在 call 里不能简单 clone 拿用——clone 出的副本处于什么状态(ready 还是未 ready)是实现细节,不可靠。

对这样的 Service,std::mem::replace(&mut self.inner, clone) 的效果:

  1. 原来的 inner(已经 ready 过的、处于 "ready for call" 状态)被 move 出来
  2. clone 副本(未 ready)被放回 self.inner
  3. Future 拿走原来的 inner——它是 ready 的,调 call 合法
  4. self 后续要用 self.inner 前需要再 poll_ready(clone 副本不是 ready 的)

HandleError 的 poll_ready 不碰 inner,两份其实都没 ready 过,靠 future 里的 oneshot 先 poll_ready 再 call,同样合法。

这个 pattern 是 Tower 里 "clone-and-replace" 惯用手法——任何需要把一次 call 交给 'static future 但又要保持 Service 调用礼仪的地方都这么写。tower 自己的 AsyncFilter::call(tower 0.5.3 tower/src/filter/mod.rs:176-188,它的 poll_ready 确实驱动了 inner)就是同一份代码,注释还写明了理由:"In case the inner service has state that's driven to readiness and not tracked by clones (such as Buffer), pass the version we have already called poll_ready on into the future, and leave its clone behind."

HandleError 的提取器支持

上面讨论的是"只接收 error"的 closure。但有时错误处理想用请求上下文——比如"记录用户 ID"、"根据 URI 决定错误消息语言"。axum/src/error_handling/mod.rs:151-222 用宏展开支持 1-16 个 FromRequestParts 提取器作为 closure 的前置参数:

// axum/src/error_handling/mod.rs:151-205 (简化)
impl<S, F, B, Res, Fut, T1, T2, ..., TN> Service<Request<B>>
    for HandleError<S, F, (T1, T2, ..., TN)>
where
    S: Service<Request<B>> + ...,
    F: FnOnce(T1, T2, ..., TN, S::Error) -> Fut + ...,
    T1: FromRequestParts<()> + Send,
    T2: FromRequestParts<()> + Send,
    // ...
{
    fn call(&mut self, req: Request<B>) -> Self::Future {
        let (mut parts, body) = req.into_parts();
        let future = Box::pin(async move {
            // 依次提取
            let t1 = match T1::from_request_parts(&mut parts, &()).await {
                Ok(v) => v,
                Err(rejection) => return Ok(rejection.into_response()),
            };
            // ...
            let req = Request::from_parts(parts, body);
            match inner.oneshot(req).await {
                Ok(res) => Ok(res.into_response()),
                Err(err) => Ok(f(t1, ..., err).await.into_response()),
            }
        });
        future::HandleErrorFuture { future }
    }
}

用法:

Router::new()
    .route("/data", get(handler))
    .layer(
        ServiceBuilder::new()
            .layer(HandleErrorLayer::new(|method: Method, uri: Uri, err: BoxError| async move {
                tracing::error!(method = %method, uri = %uri, err = ?err, "request failed");
                (StatusCode::INTERNAL_SERVER_ERROR, "error").into_response()
            }))
            .layer(TimeoutLayer::new(Duration::from_secs(5)))
    );

closure 签名 |method, uri, err| 自动触发三个参数版本的 impl——Method 和 Uri 都是 FromRequestParts、最后一个 err: BoxError 是 Service error。宏展开支持任意位置和组合——只要最后一个是 error、前面都是 FromRequestParts。

注意 bound 里是 FromRequestParts<()>——state 类型硬编码为 ()。HandleError 不接收 State——错误处理不应该依赖 Router 级别的 state(这让 HandleError 能通用到任何 Router)。如果错误处理真的需要 state,应该在 closure 外捕获 Arc 的拷贝:

let logger = Arc::new(Logger::new());
let logger2 = logger.clone();
.layer(HandleErrorLayer::new(move |err: BoxError| {
    let logger = logger2.clone();
    async move {
        logger.log(format!("{err}"));
        // ...
    }
}))
sequenceDiagram
    participant C as 客户端
    participant HE as HandleError
    participant EX as FromRequestParts 提取器
    participant INNER as Inner Service (Timeout)
    participant H as handler
    C->>HE: Request
    HE->>HE: parts, body = req.into_parts()
    loop 依次提取
        HE->>EX: T1::from_request_parts(&mut parts)
        EX-->>HE: Ok(T1) 或 Err(rejection)
    end
    HE->>HE: req = Request::from_parts(parts, body)
    HE->>INNER: oneshot(req)
    alt 成功
        INNER->>H: handler 执行
        H-->>INNER: Response
        INNER-->>HE: Ok(Response)
        HE-->>C: Response
    else 失败(如 Timeout)
        INNER-->>HE: Err(Elapsed)
        HE->>HE: f(T1, ..., Elapsed).await
        HE-->>C: Response (转换后)
    end

BoxError 与 downcast 的陷阱

closure 里判断错误类型常用 err.is::<T>() 和 err.downcast_ref::<T>()——两者都通过 TypeId 比较。几个容易踩的坑:

一、类型必须和中间件内部使用的精确一致:

// ❌ Tower 的 Elapsed 在 tower::timeout::error 里
err.is::<tokio::time::error::Elapsed>()  // false! 这是 tokio 的同名类型, 不是 tower 的

// ✅ 具体类型
use tower::timeout::error::Elapsed;
err.is::<Elapsed>()

依赖图里同时存在两个大版本的 tower 时也一样:你 use 的 Elapsed 如果来自另一个 tower 版本,和中间件实际产生的不是同一个类型——编译照样通过,但 is 返 false、静默走到 fallback 分支。

二、多层包装时 downcast 不穿透:

// err: BoxError
// 如果 err 是 Box<Box<Elapsed>>(嵌套 box)
err.is::<Elapsed>()  // false! 直接 TypeId 是 Box<Elapsed>

幸运的是 Tower 中间件一般只包一层 BoxError,这个问题较少见。但自定义的 Layer/Service 如果不小心 box 两次,这个坑会出现。debugging 时用 {err:?} 打印 Debug 输出辨认实际类型(std::any::type_name_of_val(&*err) 对 trait object 只会给出 dyn core::error::Error + ...,帮不上忙)。

三、downcast_ref::<T>() vs downcast::<T>():前者只借用(返回 Option<&T>),后者消费 err 并 own T(返回 Result<Box<T>, BoxError>)。closure 里一般用前者——方便多次判断不同类型。

四、err.source() 的链式查找:如果 error 实现了 Error::source(),可以递归找到内层错误:

use std::error::Error;

fn find_in_chain<E: Error + 'static>(err: &BoxError) -> Option<&E> {
    let mut e: &dyn Error = &**err;
    loop {
        if let Some(t) = e.downcast_ref::<E>() { return Some(t); }
        e = e.source()?;
    }
}

这能捕获"错误被别的错误 wrap"的情况——在 anyhow + 底层具体错误混用时有用。

监控错误的关键指标

生产里错误处理的健康度可以通过几个指标监控(前三个来自通用 HTTP 中间件,后两个需要自己埋点,指标名只是示意):

指标 含义 关注点
http_requests_total{status=~"5.."} rate 5xx 错误率 正常几乎为 0,持续非 0 就要看
http_requests_total{status=~"408|504"} rate 超时率(看你把 Elapsed 映射成哪个码,本章示例用 408) 非 0 意味后端慢
http_requests_total{status="503"} rate 过载率 非 0 意味容量不足
HandleError closure 的耗时分位 错误处理自身开销 明显高于同期正常响应耗时,说明 closure 里做了重操作
"连接没发响应就断"的计数 服务端没给出响应的次数 扣除客户端主动断开后应当接近 0

最后一行最重要——axum 的 Infallible 设计就是为了让这个指标趋近 0。HandleError 忘加在 Router 上会直接编译失败,所以如果它真的非 0,要查的是没挂 CatchPanicLayer 时的 panic、响应 body 流中途出错,或绕过 Router 直接交给 hyper 的自定义 Service。

大多数 APM 工具(Sentry、Datadog、Grafana)自带 HTTP request 指标——确保这些基础指标在监控 dashboard 上。

axum::Error:通用错误包装器

axum-core/src/error.rs 定义了 axum::Error(全文只有 35 行):

pub struct Error {
    inner: BoxError,
}

impl Error {
    pub fn new(error: impl Into<BoxError>) -> Self {
        Self { inner: error.into() }
    }

    pub fn into_inner(self) -> BoxError { self.inner }
}

就一个字段——BoxError = Box<dyn std::error::Error + Send + Sync>。为什么需要这个包装?

一、给 axum 内部类型统一的错误类型:Body::Error、某些 Extract 过程的中间错误,都用 axum::Error 作为 boxed wrapper。统一后外层代码只需处理一种 Error。

二、隐藏 BoxError 细节:BoxError 的类型签名到处出现会让 API 冗长、客户端不用关心具体错误类型。axum::Error 是一个 named newtype,文档和错误消息更友好。

三、保持可恢复性:Error::into_inner() 可以拿回 BoxError——下游想 downcast 成具体类型还能做。

日常代码不太用到 axum::Error——handler 里的错误都走 Result<T, E: IntoResponse> 路径。但在写自定义中间件、自定义 Body、自定义 Extract 时会遇到——这是跨组件错误流动的"大一统"类型。

handler 错误 vs 中间件错误:两种路径的边界

Axum 里有两套错误处理路径,边界值得讲清:

维度 handler 错误 中间件错误
错误类型 E: IntoResponse(业务自定义) Service::Error(通常 BoxError / Tower 类型)
触发时机 handler 函数体执行时 中间件在 Service::call 里返回 Err
处理位置 handler 内部 Result<T, E> + ? Router 级别的 HandleError middleware
产出 Response(通过 E::into_response()) Response(通过 HandleError 的 closure)
编译期保证 任何 E: IntoResponse 都行 closure 返回的 Future 的输出必须实现 IntoResponse
典型错误 业务错误: 404、数据库查询失败、验证失败 系统错误: 超时、过载、buffer 关闭
语义 "我的业务逻辑决定这次失败" "框架层面的资源/时间限制触发了"

这个分化贴合工程直觉:业务错误归业务、系统错误归系统。业务错误应该能产生精细的 HTTP 响应(不同业务错对应不同状态码和 body);系统错误通常映射到通用状态码(503 overloaded、408/504 timeout)——closure 决定映射表。

一个 Router 里可以混用两种路径——handler 用 Result<T, AppError>、middleware 栈用 HandleErrorLayer——两者互不干涉:

Router::new()
    .route("/api", post(handler))
    .layer(
        ServiceBuilder::new()
            .layer(HandleErrorLayer::new(timeout_error_handler))
            .layer(TimeoutLayer::new(Duration::from_secs(10)))
    );

// handler 里自己处理业务错误
async fn handler(Json(req): Json<CreateReq>) -> Result<Json<Data>, AppError> {
    if req.is_invalid() { return Err(AppError::BadInput); }
    let data = db.create(req).await?;  // DB 错通过 From<DbError> for AppError 自动转
    Ok(Json(data))
}

两层错误各自负责。最终客户端看到的响应都通过 IntoResponse 构造——框架保证连接不会因为错误默默断开。

HandleError::new:直接使用不经 Layer

HandleErrorLayer 是 Router 级别应用时方便,但有时只想给单个 Service 穿上错误处理——用 HandleError::new(inner, closure) 直接构造。典型场景是 Router::route_service 挂一个可失败 Service:

use axum::{
    error_handling::HandleError,
    body::Body,
    http::{Request, Response, StatusCode},
    Router,
};

async fn maybe_fail_service(req: Request<Body>) -> Result<Response<Body>, anyhow::Error> {
    // 可能失败的 Tower Service 逻辑
    if req.uri().path() == "/broken" {
        anyhow::bail!("something broke");
    }
    Ok(Response::new(Body::from("ok")))
}

let fallible = tower::service_fn(maybe_fail_service);

let app = Router::new().route_service(
    "/api",
    HandleError::new(fallible, |err: anyhow::Error| async move {
        (StatusCode::INTERNAL_SERVER_ERROR, format!("error: {err}"))
    }),
);

HandleError::new(inner, f) 直接拼成一个 Service<Request>——适合 Router 直接 route_service。Layer 形式(HandleErrorLayer)更适合在 Tower 中间件栈里混用其他 Layer。两者等价——HandleErrorLayer::new(f).layer(inner) 就是 HandleError::new(inner, f)。

选择标准:单个 service 用 HandleError::new;Router 级别 用 HandleErrorLayer。

反模式:几种错误处理的常见陷阱

反模式一:_ => 500:

// ❌ 所有 err 都给 500, 掩盖了真实错误
|err: BoxError| async move {
    (StatusCode::INTERNAL_SERVER_ERROR, "error").into_response()
}

生产时排查困难——运维看到 500 但分不清是超时、过载、还是别的。至少要分 Elapsed(408 或 504)、Overloaded(503)、其他(500)三类。

反模式二:在 closure 里 panic:

// ❌ closure panic 会让 tokio task panic, 连接被粗暴终止
// (示意:edition 2024 下这个 async 块的输出类型回退为 `!`,不满足 IntoResponse,照抄编译不过)
|err: BoxError| async move {
    panic!("unexpected error: {err}");
}

HandleError 在响应路径上——panic 让 hyper 的 task 挂掉、连接关闭、客户端看不到响应。和 Infallible 设计的初衷正好相反。

反模式三:错误信息泄露到客户端:

// ❌ 直接把内部错误字符串发给客户端
|err: BoxError| async move {
    (StatusCode::INTERNAL_SERVER_ERROR, format!("{err:?}")).into_response()
}

{err:?} 可能包含 SQL 查询、file paths、stack trace——是信息泄露。响应里给通用消息,具体错误只打日志。

反模式四:HandleErrorLayer 挂错位置:

// ❌ HandleErrorLayer 在 Timeout 之内, 看不到 Timeout 错误
ServiceBuilder::new()
    .layer(TimeoutLayer::new(Duration::from_secs(5)))
    .layer(HandleErrorLayer::new(handler))

Tower 的 ServiceBuilder 按声明顺序从外到内——HandleError 在最里面的话,Timeout 错误在它之前已经向外传——HandleError 根本接不到。挂到 Router 上时整条栈的 Error 仍是 BoxError,同样是 E0277 编译失败(axum 0.8.9 实测)。正确顺序:HandleError 在 Timeout 之前(声明顺序上)。

反模式五:不加 TraceLayer:

// ❌ 没 tracing, 错误发生时无上下文
ServiceBuilder::new()
    .layer(HandleErrorLayer::new(handler))
    .layer(TimeoutLayer::new(Duration::from_secs(5)))

错误日志里只有"超时",不知道超时的是哪个 endpoint、什么请求参数。实际生产一定要配 tower_http::trace::TraceLayer 在最外层——它打开 span、让 closure 里 tracing::error! 自动带上 method / URI / version(DefaultMakeSpan 的字段,tower-http 0.6.8 src/trace/make_span.rs)。

实战:组装一个健壮的 Router

把学到的组装一个生产级 Router:

use axum::{
    error_handling::HandleErrorLayer,
    extract::DefaultBodyLimit,
    http::{StatusCode, Method, Uri},
    response::IntoResponse,
    routing::{get, post},
    Router,
    BoxError,
};
use tower::{limit::GlobalConcurrencyLimitLayer, timeout::error::Elapsed, ServiceBuilder};
use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer};
use std::time::Duration;

async fn handle_middleware_error(
    method: Method,
    uri: Uri,
    err: BoxError,
) -> impl IntoResponse {
    if err.is::<Elapsed>() {
        tracing::warn!(method = %method, uri = %uri, "request timed out");
        (StatusCode::REQUEST_TIMEOUT, "request timed out")
    } else if err.is::<tower::load_shed::error::Overloaded>() {
        tracing::warn!(method = %method, uri = %uri, "overloaded");
        (StatusCode::SERVICE_UNAVAILABLE, "service overloaded, retry later")
    } else {
        tracing::error!(method = %method, uri = %uri, err = ?err, "middleware error");
        (StatusCode::INTERNAL_SERVER_ERROR, "internal error")
    }
}

fn app() -> Router {
    let middleware = ServiceBuilder::new()
        .layer(TraceLayer::new_for_http())
        .layer(HandleErrorLayer::new(handle_middleware_error))
        .load_shed()
        .layer(GlobalConcurrencyLimitLayer::new(1000))
        .timeout(Duration::from_secs(30));

    Router::new()
        .route("/api/data", get(fetch_data))
        .route("/api/upload", post(upload))
        .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024))  // 10MB
        .layer(middleware)
        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
}

每个组件的职责:

  • TraceLayer:每次请求的 span,记录方法、URI、状态码、耗时
  • HandleErrorLayer + error handler closure:把下游中间件的任何 Error 转成 Response
  • load_shed:内层 poll_ready 返回 Pending(并发额度用完)时直接丢请求,由 closure 映射成 503
  • GlobalConcurrencyLimitLayer:所有路由合计最多同时处理 1000 个请求
  • timeout:30 秒超时
  • RequestBodyLimitLayer:整个请求体最大 10MB

为什么不用 .concurrency_limit(1000)?Router::layer 不是在整个 Router 外面包一层,而是给每个路由的每个方法端点各套一份 layer(axum/src/routing/path_router.rs 的 layer、method_routing.rs 的 MethodRouter::layer);ConcurrencyLimitLayer::layer 每调用一次就新建一个信号量(tower 0.5.3 tower/src/limit/concurrency/layer.rs:23-25),限额因此是按端点算的。更隐蔽的是,get(handler) 这类 handler 端点上的 layer 是懒应用的(method_routing.rs 的 MethodEndpoint::map 对 BoxedHandler 只记下这个变换),要等 with_state 把 handler 变成 Route 时才真正套上。像上面这样从未调用 with_state 的 Router<()>,axum::serve 每接受一条新连接才调用一次 with_state(())(axum/src/routing/mod.rs 里 Service<IncomingStream> 的 call)——结果每条连接都拿到一份新信号量。axum 0.8.9 实测:.concurrency_limit(1) 时,两条连接同时请求同一路由都返回 200。如果先 .layer(...) 再 .with_state(state),layer 在 with_state 那一刻套上,就回到每端点一份;route_service / nest_service / get_service 挂的 Service 本来就是 Route,在 Router::layer 调用时就已套上,同样是每端点一份。GlobalConcurrencyLimitLayer 在构造时就建好信号量,layer 时只 clone 共享的那一份(同文件 57-59 行),实测同样的并发下只有一个请求拿到 200、其余 503。

注意 .load_shed() / .timeout() 这两个 ServiceBuilder 方法和 GlobalConcurrencyLimitLayer 来自 tower 的 load-shed、timeout、limit feature。axum 0.8.9 依赖 tower 时只声明了 util feature(axum/Cargo.toml:tower = { version = "0.5.2", default-features = false, features = ["util"] }),默认 feature 再经 tower-log = ["tower/log"] 和 tokio 里的 tower/make 带进 log、make 两个,都不含上面这三个,所以要用它们必须在自己的 Cargo.toml 里显式依赖 tower 并打开对应 feature。

Layer 顺序很关键——Tower 的 ServiceBuilder 按声明顺序从外到内包装。由于 Router::layer 套在每个端点上,Router 先按路径和方法匹配到端点,请求再依次经过:DefaultBodyLimit → Trace → HandleError → LoadShed → GlobalConcurrencyLimit → Timeout → RequestBodyLimit → handler。响应逆向回溯。

HandleErrorLayer 在 load_shed、并发限制、timeout 之外——它接收 LoadShed 的 Overloaded 和 Timeout 的 Elapsed(并发限制本身不产生错误,只让 poll_ready 等待)。closure 用 err.is::<ErrType>() 做 downcast 判断具体错误类型、分别处理。

panic 与错误处理的边界

Rust 的 panic! 不是错误——它是异常终止控制流。tokio 任务里 panic 会让那个任务终止,但不会传给其他任务。axum::serve 给每条连接 spawn 一个任务(axum/src/serve/mod.rs 里的连接处理块),所以一个 handler panic 不会让整个 server 挂掉。影响范围取决于协议:HTTP/1 下 handler 的 future 就跑在连接任务里,panic 带走的是当前这条连接;HTTP/2(开启 http2 feature)下 hyper 给每个流单独 spawn 任务(hyper 1.9.0 src/proto/h2/server.rs 的 execute_h2stream),只有这个流被 reset,同一连接上的其它流不受影响。

但 panic 发生时客户端看到什么?默认行为:

  • hyper 不会 catch_unwind——panic 直接向上炸穿所在的任务:HTTP/1 下连接被关掉,HTTP/2 下流被 reset,都不发响应
  • 客户端看到连接断开或流被重置(axum 0.8.9 实测:HTTP/1 下 curl 报 "Empty reply from server",HTTP/2 下报流 CANCEL)——和 Infallible 要避免的场景一样

这和 HandleError 解决的问题部分重叠但不完全一样——HandleError 处理的是 Result::Err,不是 panic。要处理 panic 需要 tower_http::catch_panic::CatchPanicLayer:

use tower_http::catch_panic::CatchPanicLayer;

Router::new()
    .route("/", get(handler))
    .layer(CatchPanicLayer::new());

CatchPanic 内部用 std::panic::catch_unwind 把 panic 捕获、转成 500 Response。客户端至少能看到"Internal Server Error"而不是连接断开。

为什么 axum 不内置 panic 处理?因为 panic 的语义复杂——catch_unwind 需要类型 UnwindSafe,有些数据结构 panic 后状态不一致再用会更糟。axum 把决定留给用户——生产里几乎总是应该加 CatchPanicLayer。

何时 panic:handler 代码里应该极少 panic——业务错误用 Result、非业务错误是 bug(unwrap on None、assert 失败)。bug 的 panic 被 CatchPanic 抓到变成 500——客户端看到错误、监控报警、开发人员去修 bug。

catch_unwind 的局限:abort 策略下的 panic(Cargo.toml 的 profile 里设了 panic = "abort")会直接终止进程,无法被 unwind 捕获。生产部署要保持默认的 panic = "unwind",别改成 abort——否则 CatchPanic 也没用。

一张图总览错误处理的完整路径

把错误产生到响应发出的完整路径画在一张图里:

flowchart TD
    REQ[客户端请求] --> ROUTER[Router]
    ROUTER --> LAYERS[Tower Layer 栈]
    LAYERS --> CP{CatchPanicLayer}
    CP -->|正常| HE{HandleErrorLayer}
    CP -->|panic 被捕获| P_RESP[500 Internal Server Error]
    HE -->|正常| INNER[Timeout/LoadShed/...]
    HE -->|Err| CLOSURE[closure err 转 Response]
    INNER --> HS[HandlerService]
    HS --> H[handler]
    H -->|Ok T| T_RESP[T 实现 IntoResponse]
    H -->|Err E| E_RESP[E 实现 IntoResponse]
    T_RESP --> COMBINE[组合成 Response]
    E_RESP --> COMBINE
    CLOSURE --> COMBINE
    P_RESP --> COMBINE
    COMBINE --> HYPER[hyper 写 TCP]
    HYPER --> CLIENT[客户端]
    style P_RESP fill:#fee
    style CLOSURE fill:#ffd
    style E_RESP fill:#eef
    style T_RESP fill:#efe

四条响应路径:

  1. 成功 handler(绿):最常见的 2xx 响应
  2. handler 返 Err(蓝):业务错误走 IntoResponse
  3. 中间件返 Err(黄):HandleError 的 closure 做转换
  4. panic(红):CatchPanic 捕获

所有路径最终都到 hyper 写 TCP 节点——没有一条路径会让连接静默断开。这是 axum 错误模型的核心承诺。

Tower 生态与 axum 错误模型的对话

Tower 的 Service trait 设计之初没有限制 Error 类型——它是通用 RPC / HTTP / 自定义协议都用的抽象。各种 Tower 中间件的 Error 选择反映其语义:

  • Timeout<S>::Error = BoxError——超时是 wrapping 自身带的错误 + 外加 Elapsed
  • Buffer<S>::Error = BoxError——buffer 本身可能 closed
  • ConcurrencyLimit<S>::Error = S::Error——不引入新错误
  • LoadShed<S>::Error = BoxError——引入 Overloaded

BoxError = Box<dyn std::error::Error + Send + Sync>——类型擦除的错误。失去编译期类型信息、获得跨 middleware 组合的灵活性。缺点是用错误时得 downcast:err.downcast_ref::<Elapsed>() 或 err.is::<Elapsed>()。

Axum 通过 HandleError 的 closure 把 BoxError 重新"结构化"——closure 内部做 downcast、按类型分发响应策略。这不是理想——理想是 Error 类型本身就结构化——但在类型擦除的实用约束下是最好的做法。

更深层的设计讨论见《Hyper 与 Tower:工业级 HTTP 栈》专栏第 2 章——那里讨论了 Tower Service 为什么把 Error 设计成关联类型而不是具体类型、BoxError 的 trait object 成本、以及"类型擦除的错误流动"在 Service composition 中的地位。读那一章后再回头看 HandleError 的 S::Error 到 Infallible 的翻译,你会理解这一翻译不是 axum 的任意设计,而是 HTTP 框架必须解决的工程问题的合理答案。

测试错误处理的模式

错误处理代码不测试是 bug 温床——正常路径跑得起不代表异常路径正确。几种有效测试方式:

一、用 tower::ServiceExt::oneshot 调用 HandleError Service:

#[tokio::test]
async fn timeout_returns_408() {
    use axum::{body::Body, http::{Request, Response}};
    use tower::{timeout::TimeoutLayer, ServiceExt};
    use std::time::Duration;

    let slow_service = tower::service_fn(|_req: Request<Body>| async {
        tokio::time::sleep(Duration::from_secs(2)).await;
        Ok::<_, BoxError>(Response::new(Body::empty()))
    });

    let service = ServiceBuilder::new()
        .layer(HandleErrorLayer::new(handle_middleware_error))
        .layer(TimeoutLayer::new(Duration::from_millis(100)))
        .service(slow_service);

    let req = Request::new(Body::empty());
    let res = service.oneshot(req).await.unwrap();
    assert_eq!(res.status(), StatusCode::REQUEST_TIMEOUT);
}

二、对 Router 调 ServiceExt::oneshot 测整条 Router:

#[tokio::test]
async fn overload_returns_503() {
    let app = build_app();  // 完整生产 Router
    // 并发发 N+1 请求, 观察第 N+1 个返 503
    // ...
}

三、mock 失败 Service 验证 closure 分发:

#[tokio::test]
async fn specific_error_maps_to_specific_status() {
    use axum::error_handling::HandleError;

    let broken = tower::service_fn(|_req: Request<Body>| async {
        Err::<Response<Body>, _>(MyCustomError::AuthFailed)
    });

    let service = HandleError::new(broken, |err: MyCustomError| async move {
        match err {
            MyCustomError::AuthFailed => StatusCode::UNAUTHORIZED,
            MyCustomError::Other => StatusCode::INTERNAL_SERVER_ERROR,
        }
    });

    let res = service.oneshot(Request::new(Body::empty())).await.unwrap();
    assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}

测试错误处理的目标:

  • 每个错误类型都被 closure 覆盖——添加新错误类型时 grep 测试文件、补相应用例
  • fallback 分支(_ =>)有明确测试——确保未识别的错误类型也有合理响应
  • 日志 side effect——重要场景里用 tracing::subscriber mock 验证日志写了正确内容

带 state 的错误处理进阶

有时错误处理需要业务 state——比如 rate limiter 的实例、metrics registry、feature flag 系统。HandleErrorLayer 本身不接 state,但可以通过 closure 捕获 Arc 的拷贝:

use std::sync::Arc;

// Metrics、SentryClient 是示意用的自定义封装,不是某个 crate 的现成 API
#[derive(Clone)]
struct ErrorContext {
    metrics: Arc<Metrics>,
    sentry_client: Arc<SentryClient>,
}

fn build_app(ctx: ErrorContext) -> Router {
    let ctx_clone = ctx.clone();
    let error_handler = move |err: BoxError| {
        let ctx = ctx_clone.clone();
        async move {
            // 上报到 Sentry
            ctx.sentry_client.capture_error(&err).await;
            // 计入 metrics
            ctx.metrics.register_error();
            (StatusCode::INTERNAL_SERVER_ERROR, "error").into_response()
        }
    };

    Router::new()
        .route("/api", get(handler))
        .layer(
            ServiceBuilder::new()
                .layer(HandleErrorLayer::new(error_handler))
                .layer(TimeoutLayer::new(Duration::from_secs(10)))
        )
}

几个要点:

  1. move |err|:closure 按值捕获 ctx_clone——保证 closure 是 'static
  2. 内部 async move + ctx.clone():closure 要求 F: Clone,HandleError 每个请求都 clone 一份 closure 再调用,调用时再 clone 一份 Arc 进入 async block
  3. Arc<T> 让 clone 变廉价:Arc::clone 只是一次引用计数原子自增,不复制被包裹的数据

这种 pattern 在 axum 中间件生态里非常常见——第 13 章讲 from_fn 时会看到相同技术的另一种应用。

hyper 层的错误处理

axum 坚持 Infallible,但hyper 层本身还是会遇到"真正的"网络错误——客户端中途断连、TLS 握手失败、I/O 错。这些错误不经过 axum 的 Service 链——hyper 自己处理。

几种典型:

  • 客户端主动断开:客户端在收到完整响应前关闭 TCP 连接。HTTP/1 下 hyper 读到 EOF 就立即关闭连接(half_close 默认 false,hyper 1.9.0 src/server/conn/http1.rs),连接 future 结束,正在运行的 handler future 随之被 drop
  • TLS 握手失败:证书过期、SNI mismatch、TLS 版本不兼容——在 axum 的 Service 被调用前就失败了,axum 看不到
  • HTTP/2 流中断:RST_STREAM 帧让某个流提前结束——类似客户端断开但只影响一个 stream

这些错误不需要也无法经过 HandleError——它们没产生 Result::Err,只是"请求/响应被中断"。axum handler 的 future 在这种情况下会随连接(或流)的 future 一起被 drop——handler 内的所有异步资源跟随 drop(连接、文件句柄、订阅——自动清理,参见第 10 章 SSE 连接清理讨论)。

实际影响:

  • handler 可能执行一半被中断——数据库查询可能 in-flight,文件写可能未完成。业务层要考虑"这个 handler 中断是否产生不一致状态"——幂等设计或事务回滚是标准应对
  • 不要假设 "handler 返回 = 响应到客户端"——handler 完成但客户端可能已经断开,响应写不出去。这两件事是不同事件

这层错误的监控:

  • hyper 自身不导出指标,断连次数要自己在应用层统计
  • tower_http::metrics::InFlightRequestsLayer 能统计在途请求数
  • 应用层通常通过"请求开始 vs 请求完成" 数量差判断——持续有差意味着客户端大量断开

Rust 错误处理的几种惯用模式

本章讨论的 axum 错误处理是 Rust 通用错误模式的一个应用。几种主流模式:

一、? + From 自动转换:最经典、最常用:

fn f() -> Result<(), MyError> {
    let x: Result<_, A> = ...;
    let y = x?;  // 要求 MyError: From<A>, 自动转
    Ok(())
}

axum handler 里的 ? 依赖 From<DbError> for AppError 自动转——让错误流动无缝。

二、thiserror:enum 错误 + derive:

#[derive(thiserror::Error, Debug)]
enum AppError {
    #[error("not found")]
    NotFound,
    #[error("db: {0}")]
    Db(#[from] sqlx::Error),
    #[error("validation: {0:?}")]
    Validation(Vec<String>),
}

#[from] 自动派生 From<sqlx::Error> for AppError——? 无缝工作。#[error("...")] 自动 impl Display。

三、anyhow:动态错误 + context:

fn f() -> anyhow::Result<()> {
    let x = something().context("while doing X")?;
    Ok(())
}

不区分错误类型、只关心错误消息。适合 app 级别、不需要在 middleware 里按类型分发的场景。

四、eyre:anyhow 的 alternative:和 anyhow 类似但 error report 更漂亮。

选型:库代码用 thiserror(给调用者类型化错误);app 代码用 anyhow(用着方便);axum handler 建议 thiserror(IntoResponse impl 能按变体分发 HTTP 状态码)。

axum 的错误模型和这些模式兼容——impl IntoResponse for AppError 手写一次,之后 ? 无缝在 handler 里用。anyhow::Error 可以用 newtype + 通用 IntoResponse 的模式(见第 9 章"ErrorResponse vs thiserror / anyhow")。

几条错误处理最佳实践

从上面讨论总结可操作的原则:

一、handler 内部用结构化错误 enum。AppError + thiserror 让 handler 代码干净、API 错误码精细。只要你需要按错误类型分发状态码,就不要用 anyhow::Error 当 handler 错误——它把具体类型擦掉了,包一层 newtype 后 IntoResponse 只能一律返同一个状态码(通常是 500)。反过来,如果这个服务本来就只有 2xx / 500 两种结果(原型、内部工具),anyhow + newtype wrapper 更省事——见第 9 章"ErrorResponse vs thiserror / anyhow:三种错误风格"的风格 2 与那里的选型建议。

二、middleware 错误用 HandleErrorLayer + downcast。closure 里 err.is::<T>() 或 err.downcast_ref::<T>() 识别具体类型。写详尽的 match 覆盖所有可能错误——避免 _ => 500。

三、日志级别按错误性质区分。404/422 用 warn! 或不打日志;超时、过载用 warn!;5xx 内部错误用 error! 加完整上下文。

四、closure 里的异步操作要节制。HandleError closure 在响应路径上——慢操作(RPC 上报、DB 写)会阻塞响应。慢工作用 tokio::spawn fire-and-forget。

五、不要吞错误信息。err = ?err 保留完整 Debug 信息到日志——但不要直接塞进响应 body(可能有敏感信息)。

六、用 tracing span 关联错误和请求。handler 入口打开 span(比如 request_id、user_id),整条链路上的日志自动带上下文——错误发生时能回溯完整请求。

性能:错误路径的开销

错误处理代码通常不是热路径,但 HandleError 的实现细节还是值得量化:

操作 开销来源
oneshot(req).await 主要是被包装 Service 的耗时,Oneshot 自身只是一个状态机
self.f.clone() closure 的 clone,无捕获时是 ZST,编译期消除
std::mem::replace(&mut self.inner, clone) 一次 S::Clone,取决于内部类型(Route 是 BoxCloneSyncService,clone 即一次 Box 的 clone_box)
Box::pin(async move { ... }) 每次请求一次堆分配——这是 HandleError 唯一确定引入的分配
closure .await.into_response() 取决于 closure 具体工作
Ok(...) 包装 Error = Infallible,Err 分支在类型层不可达

定性结论:HandleError 在成功路径上只多一次 Box::pin 堆分配和一次 Service clone,相对 handler 里的业务逻辑(通常涉及 IO)可以忽略;失败路径额外加上 closure 自身的耗时。想去掉那次 Box::pin(几乎没必要),只能绕过 HandleError 自己写一个具名 Future 的 Service。

统一错误报告的进阶模式

生产里错误不仅要"变成响应",还要"报告到监控系统"——Sentry、Rollbar、内部告警平台。把这个逻辑用 closure 统一:

use tower::timeout::TimeoutLayer;

fn build_app() -> Router {
    Router::new()
        .route("/api", post(handler))
        .layer(
            ServiceBuilder::new()
                .layer(CatchPanicLayer::new())
                .layer(HandleErrorLayer::new(
                    |method: Method, uri: Uri, headers: HeaderMap, err: BoxError| async move {
                        // 构造 Sentry event
                        sentry::with_scope(
                            |scope| {
                                scope.set_tag("method", method.as_str());
                                scope.set_tag("uri", uri.to_string());
                                if let Some(req_id) = headers.get("x-request-id") {
                                    scope.set_tag("request-id", req_id.to_str().unwrap_or(""));
                                }
                            },
                            || {
                                let event_id = sentry::capture_error(&*err);
                                tracing::error!(sentry_id = %event_id, "error reported");
                            },
                        );

                        // 返响应给客户端
                        error_to_response(&err)
                    }
                ))
                .layer(TimeoutLayer::new(Duration::from_secs(10)))
        )
}

fn error_to_response(err: &BoxError) -> Response {
    if err.is::<Elapsed>() {
        (StatusCode::REQUEST_TIMEOUT, "timeout").into_response()
    } else if err.is::<tower::load_shed::error::Overloaded>() {
        (StatusCode::SERVICE_UNAVAILABLE, "overloaded").into_response()
    } else {
        (StatusCode::INTERNAL_SERVER_ERROR, "internal").into_response()
    }
}

几个设计决策:

一、closure 拿提取器参数:method, uri, headers——让 Sentry event 带上下文。生产环境里错误报告没上下文是最大的可观测性败笔

二、Sentry scope 包装:with_scope + set_tag 让报告里的 tag 丰富、可按 tag 过滤。url / method / request-id 是查问题最有用的维度

三、响应和报告分离:error_to_response 单独写——单元测试时容易 mock、调试时能独立调

四、异步 closure 里慢操作的节制:Sentry 的 capture_error 是同步调用、返回事件 id,只把事件交给后台传输队列,通常很快。如果是慢操作(调 slack API 发告警),应该 tokio::spawn fire-and-forget——不阻塞响应

这种模式在大型 axum 项目里是标配——错误处理 closure 几十到几百行,是项目里最复杂的 closure 之一。投资让它结构化、易改、有测试是值得的。

axum 错误处理的版本演进

axum 的错误模型不是一蹴而就——以下版本节点都能在 axum/CHANGELOG.md 里查到:

axum 0.2.x:handle_error 从 ServiceExt 挪到 service::OnMethod(#160)——错误处理还挂在"某个路由"上,closure 只服务那一个 handler。

axum 0.3.0(2021-11-02):"Simplify error handling model"(#402)是关键一刀。CHANGELOG 里写得很直白:All services part of the router are now required to be infallible;错误处理工具集中挪进新的 error_handling 模块;Router::check_infallible 因此被删掉("routers are always infallible");错误处理 closure 必须处理所有错误并返回 IntoResponse。今天这套 Infallible + HandleError 模型就是在这个版本定型的。

axum 0.4.0(2021-12-02):三条相关改动——HandleErrorExt 被 MethodRouter::handle_error 取代;HandleErrorLayer 要求错误处理函数必须是 async(#534);HandleErrorLayer 开始支持运行提取器。本章讲的 1-16 个 FromRequestParts 参数就是从这里来的,不是后来才加的。

axum 0.7.0(2023-11-27):handle_error 又以 trait 方法的形式回到 ServiceExt(#2235)——今天 axum/src/service_ext.rs 里的 ServiceExt::handle_error 和 axum/src/routing/method_routing.rs 里的 MethodRouter::handle_error 并存,分别对应"给一个 Service 穿错误处理"和"给一个 MethodRouter 穿错误处理"。

axum 0.8.x(本专栏基准):HandleError / HandleErrorLayer 本身没有再变。主线(尚未发布,源码注释称其为 axum 0.9 的行为):axum-core 里新增的 IntoResponseFailed(axum-core/src/response/mod.rs)从另一个方向补齐了防线——响应构造中途失败时打上这个扩展标记,外层的 (StatusCode, T) 元组就不会再把已经变成 500 的响应改回 200(见第 9 章)。注意它不在 crates.io 的 axum 0.8.9 / axum-core 0.5.6 里。

从这条演进看,axum 的错误模型是在"保证连接不断" + "让错误处理显式" + "API 人体工程学"三者间反复调整:0.3 立规矩、0.4 补上提取器让 closure 能拿到上下文、0.7 补回 Service 层的便利入口。0.8 已经很稳定,HandleError 这条主线在主线仓库的未发布改动里也没有再动——未发布改动主要在 serve、路由匹配、Query / Form 的反序列化器和上面那条响应失败标记等处(完整清单见专栏首页「源码版本」一节)。

和其他框架的错误处理对比

不同 Rust Web 框架处理 Service Error 的思路不同:

框架 Error 类型 错误到响应的转换
axum Service 永远 Infallible handler 返 Result + middleware 经 HandleError
actix-web ResponseError trait 错误实现 ResponseError 即自动转响应
warp Rejection chain filter 链条里每个 filter 能处理 Rejection
rocket Responder + Error Catchers 全局 catchers 按 status code 分发

每种选择都合理但有 tradeoffs:

  • axum 的 Infallible:保证不静默断连最彻底,但要求显式 HandleError 学习门槛高
  • actix-web 的 ResponseError:错误自动变响应,简单;但错误处理逻辑分散在各个错误类型 impl 里、难集中改行为
  • warp 的 Rejection:filter 链条里错误流动自然,但类型层复杂、难调试
  • rocket 的 catchers:按 status code 全局 catcher 清晰,但错误产生处和处理处分离太远、调试要跳来跳去

axum 的选择接近"守护栅栏"风格——宁可让用户多写一点,也要保证边界处不漏。这和 axum 整体"类型安全优先"的风格一致。如果你用惯了 actix-web 或 Express/Koa(自动错误映射),第一次写 axum 可能觉得 HandleError 繁琐——但在生产稳定性上这种繁琐换来的是可靠性。

小结:两层防线

Axum 的错误处理是两层防线:

  • handler 层:Result<T, E: IntoResponse> + ? 消化业务错误;框架保证 Handler 的 Future<Output = Response>
  • 中间件层:HandleErrorLayer 消化 Tower middleware 的错误;框架保证 Service 的 Error = Infallible

两层防线共同保证:连接永远不会因 Result 错误被静默关闭(panic 不在此列,需另挂 CatchPanicLayer)。客户端永远收到某种 HTTP 响应——成功、重定向、或某种错误状态——但不会是"连接断了"这种无 context 的失败。

这条保证对生产稳定性至关重要。监控系统能看到 2xx/3xx/4xx/5xx 的分布,不会漏掉"连接断开"这种隐形失败。客户端 SDK 能按状态码做 retry/backoff,不会陷入"不知道是网络问题还是服务问题"的尴尬。所有这些依赖"框架绝对不让 Error 逃到 hyper"这条不可违背的契约——而契约由 Infallible 类型和 HandleError 共同守护。

这也影响部署选择——直接暴露 axum 服务到 Internet 和躲在 nginx / ALB 后面,错误行为都应该一致。框架层面的保证让"连接层错误"从运维担忧降级到监控指标——只要 5xx 告警和 latency 告警配置合理,axum 的错误处理几乎不需要额外工具支持。

让响应永远产生、让错误永远显式

这是 axum 错误模型的一句话总结。

Infallible 不是说"错误不可能发生"——生产里任何代码都可能失败。它说的是:框架承诺,无论代码里哪一层失败,都会有某种响应最终发给客户端。这个承诺由两层防线保证——handler 层的 Result + IntoResponse、middleware 层的 HandleError。两层合起来覆盖了 handler 代码、提取器代码、中间件代码、响应构造代码——加上 CatchPanicLayer 覆盖 panic——Axum 里的错误源头都有对应机制。

显式性是代价也是收益。代价:用户必须写 HandleErrorLayer::new(closure)、必须手写 closure 里每种错误的分支。收益:错误处理逻辑集中、可审查、可测试、可修改。当生产出问题时,grep HandleErrorLayer 就能找到所有错误处理点——不像其他框架里错误行为散在各种 ResponseError impl、各种 catcher、各种 filter 组合里。

理解了本章的机制后,你读 axum 代码时看到 Error = Infallible 不会再觉得"怪"——那是类型系统在提醒你"这个 Service 已经经过错误收敛,不会再让 hyper 哭了"。看到 HandleErrorLayer 你会立刻知道"这里是从可失败 Service 到 Infallible 的边界"——这是 axum 内外世界的分界线。

下一章进入中间件——from_fn 的函数式中间件模型。有了本章对 Service 错误边界的理解,from_fn 里为什么用 Next 抽象而不是直接 Service、为什么 Next::run 返回 Response 而不是 Result——这些设计选择会变得很清晰。