Appearance
第12章 错误处理模型:Infallible 与 HandleError
第 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、TimeoutError、自定义类型),和 axum 要求的 Infallible 不兼容。这就是 HandleError 存在的原因——它是 axum 和 Tower 生态的"阻抗匹配器",把可失败 Service 的 Error 翻译成 Response,让整条链的 Error 类型重新收敛到 Infallible。
本章拆解这个机制的所有细节。
为什么 axum 坚持 Infallible
先回顾 axum 官方对错误模型的立场(axum/src/docs/error_handling.md 开头):
axum is based on
tower::Servicewhich bundles errors through its associatedErrortype. 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 = Infallible。Infallible 是 std::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::call 的 future.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::Timeout | Elapsed | 超时 |
tower::limit::ConcurrencyLimit | Overloaded | 并发数超限 |
tower::load_shed::LoadShed | Overloaded | 过载保护 |
tower::buffer::Buffer | Closed | 缓冲通道关闭 |
tower_http::limit::RequestBodyLimit | LengthLimitError | body 超大 |
这些中间件设计之初不是为了专门配合 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 定义了 HandleError 的 Service 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 返回 Ready 后 call 消费那个 readiness(类似 "token")。直接 clone 可能复制未 ready 的版本。先 replace、再 move 是保持 Tower 语义的安全做法。
二、inner.oneshot(req).await:tower::ServiceExt::oneshot 把 Service + Request 组合成一次性调用——先 poll_ready 等 ready,然后 call。oneshot 是 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: 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 里的这段:
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) 的效果:
- 原来的 inner(已经 ready 过的、处于 "ready for call" 状态)被 move 出来
- clone 副本(未 ready)被放回 self.inner
- Future 拿走原来的 inner——它是 ready 的,调 call 合法
- self 后续要用 self.inner 前需要再 poll_ready(clone 副本不是 ready 的)
这个 pattern 是 Tower 里 "clone-and-replace" 惯用手法——任何需要 spawn 一个 call 但又要保持 Service 调用礼仪的地方都这么写。tower::util::BoxService、tower::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——Method 和 Uri 都是 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.."} rate | 5xx 错误率 | 正常几乎 0,持续非 0 就要看 |
http_requests_total{status="504"} rate | 超时率 | 非 0 意味后端慢 |
http_requests_total{status="503"} rate | 过载率 | 非 0 意味容量不足 |
handle_error_closure_duration p99 | HandleError 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)。
选择标准:单个 service 用 HandleError::new;Router 级别 用 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。响应逆向回溯。
HandleErrorLayer 在 load_shed、concurrency_limit、timeout 之外——它接收所有三者可能产生的错误。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 也没用。
一张图总览错误处理的完整路径
把错误产生到响应发出的完整路径画在一张图里:
四条响应路径:
- 成功 handler(绿):最常见的 2xx 响应
- handler 返 Err(蓝):业务错误走 IntoResponse
- 中间件返 Err(黄):HandleError 的 closure 做转换
- panic(红):CatchPanic 捕获
所有路径最终都到 hyper 写 TCP 节点——没有一条路径会让连接静默断开。这是 axum 错误模型的核心承诺。
Tower 生态与 axum 错误模型的对话
Tower 的 Service trait 设计之初没有限制 Error 类型——它是通用 RPC / HTTP / 自定义协议都用的抽象。各种 Tower 中间件的 Error 选择反映其语义:
Timeout<S>::Error = BoxError——超时是 wrapping 自身带的错误 + 外加 ElapsedBuffer<S>::Error = BoxError——buffer 本身可能 closedConcurrencyLimit<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::Error 到 Infallible 的翻译,你会理解这一翻译不是 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::subscribermock 验证日志写了正确内容
带 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)))
)
}几个要点:
move |err|:closure 按值捕获ctx_clone——保证 closure 是'static- 内部
async move+ctx.clone():closure 可能被FnOnce::clone出多份副本给不同请求用,每次调用时再 clone 一份 Arc 进入 async block 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 内部用结构化错误 enum。AppError + 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 类型 | 错误到响应的转换 |
|---|---|---|
| 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
两层防线共同保证:连接永远不会因错误被静默关闭。客户端永远收到某种 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——这些设计选择会变得很清晰。