Skip to content

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

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

但这个模型有个边界——Tower 中间件。Axum 的 Router 可以挂任意 Tower middleware 做请求变换、限流、超时、重试、分流。这些中间件的 Service 通常有真实的错误类型(BoxErrorTimeoutError、自定义类型),和 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 收到 Err 的响应会直接关闭 TCP 连接,不发任何 HTTP 响应。客户端收到的不是 "500 Internal Server Error",而是 "Connection closed"——对用户和运维都极不友好。客户端可能看到浏览器错误页、重试逻辑可能把失败当成偶发网络问题、日志里看不到具体 handler 发生了什么。

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

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

Infallible 的类型级保证

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

rust
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::callfuture.map(Ok as _) 把 Response 包进 Result<Response, Infallible>,外部拿到 Ok(response) 就直接用——编译器优化掉了 Err 检查。

never type 与 Infallible 的关系

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

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

rust
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 生态的通用中间件。列出几种典型:

中间件潜在错误原因
tower::timeout::TimeoutElapsed超时
tower::limit::ConcurrencyLimitOverloaded并发数超限
tower::load_shed::LoadShedOverloaded过载保护
tower::buffer::BufferClosed缓冲通道关闭
tower_http::limit::RequestBodyLimitLengthLimitErrorbody 超大

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

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

rust
// ❌ 编译错误:TimeoutLayer 让 Error 变成 BoxError, 不是 Infallible
Router::new()
    .route("/slow", get(slow_handler))
    .layer(TimeoutLayer::new(Duration::from_secs(5)));

编译器会抱怨"expected Infallible, found BoxError"之类。必须用 HandleErrorLayer 把 Timeout 的错误先转回 Infallible:

rust
// ✅ 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 定义了 HandleErrorService impl(无提取器参数版本):

rust
// 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 返回 Readycall 消费那个 readiness(类似 "token")。直接 clone 可能复制未 ready 的版本。先 replace、再 move 是保持 Tower 语义的安全做法。

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

三、match 分发

rust
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: IntoResponseawaitinto_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 里的这段:

rust
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。这意味着 &mut self.inner 在 call 里不能简单 clone 拿用——clone 出的副本处于什么状态(ready 还是未 ready)是实现细节,不可靠。

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 的)

这个 pattern 是 Tower 里 "clone-and-replace" 惯用手法——任何需要 spawn 一个 call 但又要保持 Service 调用礼仪的地方都这么写。tower::util::BoxServicetower::buffer::Buffer::new_worker_task 等都是同样模式。

HandleError 的提取器支持

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

rust
// 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 }
    }
}

用法:

rust
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——MethodUri 都是 FromRequestParts、最后一个 err: BoxError 是 Service error。宏展开支持任意位置和组合——只要最后一个是 error、前面都是 FromRequestParts。

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

rust
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}"));
        // ...
    }
}))

BoxError 与 downcast 的陷阱

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

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

rust
// ❌ Tower 的 Elapsed 在 tower::timeout::error 里
err.is::<std::time::Duration>()  // 肯定不是, Elapsed 不是 Duration

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

不同版本的 tower 可能把 Elapsed 挪到不同模块——代码升级 tower 版本时 downcast 可能静默失效(类型变了、is 返 false、走到 fallback 分支)。

二、多层包装时 downcast 不穿透

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

幸运的是 Tower 中间件一般只包一层 BoxError,这个问题较少见。但自定义的 Layer/Service 如果不小心 box 两次,这个坑会出现。debugging 时打印 std::any::type_name 看实际类型名。

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

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

rust
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_requests_total{status=~"5.."} rate5xx 错误率正常几乎 0,持续非 0 就要看
http_requests_total{status="504"} rate超时率非 0 意味后端慢
http_requests_total{status="503"} rate过载率非 0 意味容量不足
handle_error_closure_duration p99HandleError closure 耗时应该 < 1ms; 长了说明里面做了重操作
connection_terminated_without_response连接没发响应就断的次数必须 0(axum 设计保证)

最后一行最重要——axum 的 Infallible 设计就是为了让这个指标永远是 0。如果真的非 0,要么是 HandleError 忘加、要么是自定义 Service 绕过了 axum 机制——都要立即排查。

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

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

axum-core/src/error.rs:1-36 定义了 axum::Error

rust
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 必须返回 IntoResponse
典型错误业务错误: 404、数据库查询失败、验证失败系统错误: 超时、过载、body 超大
语义"我的业务逻辑决定这次失败""框架层面的资源/时间限制触发了"

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

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

rust
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:

rust
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)

选择标准:单个 serviceHandleError::newRouter 级别HandleErrorLayer

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

反模式一:_ => 500

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

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

反模式二:在 closure 里 panic

rust
// ❌ closure panic 会让 tokio task panic, 连接被粗暴终止
|err: BoxError| async move {
    panic!("unexpected error: {err}");
}

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

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

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

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

反模式四:HandleErrorLayer 挂错位置

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

Tower 的 ServiceBuilder 按声明顺序从外到内——HandleError 在最里面的话,Timeout 错误在它之前已经向外传——HandleError 根本接不到。正确顺序:HandleError 在 Timeout 之前(声明顺序上)。

反模式五:不加 TraceLayer

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

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

实战:组装一个健壮的 Router

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

rust
use axum::{
    error_handling::HandleErrorLayer,
    extract::DefaultBodyLimit,
    http::{StatusCode, Method, Uri},
    response::IntoResponse,
    routing::{get, post},
    Router,
    BoxError,
};
use tower::{BoxError as _, 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()
        .concurrency_limit(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:服务过载(concurrency_limit 达到且还有 Pending)时丢请求返 503
  • concurrency_limit:最多同时处理 1000 个请求
  • timeout:30 秒超时
  • RequestBodyLimitLayer:整个请求体最大 10MB

Layer 顺序很关键——Tower 的 ServiceBuilder 按声明顺序从外到内包装。请求依次经过:Trace → HandleError → LoadShed → ConcurrencyLimit → Timeout → RouterCore。响应逆向回溯。

HandleErrorLayerload_shedconcurrency_limittimeout 之外——它接收所有三者可能产生的错误。closure 用 err.is::<ErrType>() 做 downcast 判断具体错误类型、分别处理。

panic 与错误处理的边界

Rust 的 panic! 不是错误——它是异常终止控制流。tokio 任务里 panic 会让那个任务 abort,但不会传给其他任务。对 axum:一个 handler panic 只影响当前请求,不会让整个 server 挂掉。

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

  • hyper + tokio 的组合会捕获 handler 任务的 panic——把连接关掉(发响应)
  • 客户端看到 "connection reset"——和 Infallible 要避免的场景一样

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

rust
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(比如 tokio runtime 自己的 panic handler 配置成 abort)无法被 unwind 捕获。生产部署要配 panic = "unwind"(Cargo.toml)而非 abort——否则 CatchPanic 也没用。

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

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

四条响应路径:

  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 栈》第 4 章——那里讨论了 Tower Service::Error 为什么是关联类型而不是具体类型、BoxError 的 trait object 成本、以及"类型擦除的错误流动"在 Service composition 中的地位。读那一章后再回头看 HandleError 的 S::ErrorInfallible 的翻译,你会理解这一翻译不是 axum 的任意设计,而是 HTTP 框架必须解决的工程问题的合理答案。

测试错误处理的模式

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

一、用 tower::ServiceExt::ready().await.call() 调用 HandleError Service

rust
#[tokio::test]
async fn timeout_returns_504() {
    use tower::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::oneshot 测整条 Router:

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

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

rust
#[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 的拷贝:

rust
use std::sync::Arc;

#[derive(Clone)]
struct ErrorContext {
    metrics: Arc<prometheus::Registry>,
    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 可能被 FnOnce::clone 出多份副本给不同请求用,每次调用时再 clone 一份 Arc 进入 async block
  3. Arc<T> 让 clone 零成本Arc::clone 是原子加一,比 clone 整个 state 快几个数量级

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

hyper 层的错误处理

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

几种典型:

  • 客户端主动断开:客户端在收到完整响应前关闭 TCP 连接。hyper 的 body writer 会注意到、abort 正在运行的 handler future(通过 tokio 取消语义)
  • TLS 握手失败:证书过期、SNI mismatch、TLS 版本不兼容——在 axum 的 Service 被调用前就失败了,axum 看不到
  • HTTP/2 流中断:RST_STREAM 帧让某个流提前结束——类似客户端断开但只影响一个 stream

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

实际影响:

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

这层错误的监控:

  • hyper::connection::abort 等指标——但大多数框架不暴露
  • tower_http::metrics 提供 HTTP level 的一些指标
  • 应用层通常通过"请求开始 vs 请求完成" 数量差判断——持续有差意味着客户端大量断开

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

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

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

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

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

二、thiserror:enum 错误 + derive

rust
#[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

rust
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 内部用结构化错误 enumAppError + thiserror 让 handler 代码干净、API 错误码精细。不要用 anyhow::Error 当 handler 错误——它让 IntoResponse 实现不能按错误类型分发。

二、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 耗时(本身开销 < 50 ns)
self.f.clone()closure 本身 clone(通常 ZST,0 ns)
std::mem::replace(&mut self.inner, clone)Service clone(取决于内部类型,Arc-based ~10 ns)
Box::pin(async move { ... })一次堆分配 + pin,~50 ns
closure .await.into_response()取决于 closure 具体工作
Ok(...) 包装编译期消除

总开销:成功路径(未失败)~100 ns;失败路径加上 closure 耗时(几百 ns 到几 ms)。对比 handler 业务逻辑(通常微秒到毫秒)——HandleError 的开销可忽略。

失败路径上的 Box::pin 分配是唯一"像热点"的地方——但失败本来就应该少、单次多一次堆分配可接受。想把它避免的话(几乎没必要),需要绕过 HandleError 自己实现 Service。

统一错误报告的进阶模式

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

rust
use sentry::Hub;

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 是异步但通常快(内部有 queue)。如果是慢操作(调 slack API 发告警),应该 tokio::spawn fire-and-forget——不阻塞响应

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

axum 错误处理的版本演进

axum 的错误模型不是一蹴而就——几个版本间的改动反映了设计思路的修正:

axum 0.5 及更早Router::route("/x", handler.handle_error(closure))——错误处理是 handler 自身的一个方法。缺点是 closure 只能处理那一个 handler 的错误,多 handler 要写多份。

axum 0.6:引入 HandleError 作为 Service 层抽象、HandleErrorLayer 作为 Layer。错误处理可以 Router 级应用、跨多个 handler 共享——接近现在的形态。

axum 0.7+:稳定下来。主要改进在 HandleErrorLayer 的 closure 参数支持——从固定签名到 1-16 个 FromRequestParts 提取器,让 closure 能取 method/uri/headers 等上下文。

axum 0.9 的相关改动:虽然不直接影响 HandleError,但 IntoResponseFailed 让"错误响应不被外层 status 覆盖"——和 HandleError 一起形成完整防线。

axum 0.10 预期:社区讨论是否把 HandleError 作为默认行为的一部分——比如 Router 自动包一层、closure 不强制用户提供。但这会降低"错误处理显式化"的好处,尚无定论。

从这条演进看,axum 的错误模型是在"保证连接不断" + "让错误处理显式" + "API 人体工程学"三者间反复调整。每个版本的改动都在优化某一维——0.6 加 Layer 提升复用性、0.7 加提取器支持提升信息密度、0.9 加 IntoResponseFailed 封堵最后漏洞。稳定版本的 API 是多年迭代的产物。

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

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

框架Error 类型错误到响应的转换
axumService 永远 Infalliblehandler 返 Result + middleware 经 HandleError
actix-webResponseError trait错误实现 ResponseError 即自动转响应
warpRejection chainfilter 链条里每个 filter 能处理 Rejection
rocketResponder + 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

两层防线共同保证:连接永远不会因错误被静默关闭。客户端永远收到某种 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——这些设计选择会变得很清晰。

基于 VitePress 构建