CatchPanic
主线位置:作用点主线的前两个时刻,
call时和响应 future 完成时;结局主线上,它把「没有响应」(任务被 panic 打断)变成一个 500,让外层的 Trace、InFlightRequests 照常看到一个结局。body 被 poll 时的 panic 不归它管,这一点和它在 Trace 里外的效果,axum 卷 其余常用中间件已经实测过。
flowchart TB
callNode["CatchPanic::call"] --> c1{"catch_unwind(inner.call)"}
c1 -->|panic| p["Kind::Panicked:留着 panic 信息"]
c1 -->|返回 future| f["Kind::Future:future.catch_unwind()"]
f -->|"Ok(Ok(res))"| box["body 擦成 UnsyncBoxBody"]
f -->|"Ok(Err(e))"| err["原样返回 Err"]
f -->|panic| p2[交给 ResponseForPanic]
p --> p2
p2 --> r500["500 Service panicked"]
又一个短路枚举
CatchPanic::call 和响应 future 的写法,axum 卷那一节贴过全文。放到四件套的框架里看,它就是一个短路模块,Kind 两个变体:
enum Kind<F, T> {
Panicked {
panic_err: Option<Box<dyn Any + Send + 'static>>,
panic_handler: Option<T>,
},
Future {
#[pin]
future: CatchUnwind<AssertUnwindSafe<F>>,
panic_handler: Option<T>,
}
}
Panicked 是 call 时就 panic 了,还没拿到内层的 future:先把 panic 信息存起来,等第一次 poll 时再交给处理器造响应。Future 是正常拿到了 future,用 futures 的 catch_unwind 包一层,poll 时 panic 就变成 Err(panic_err)。
和别的短路模块不同,它不要求响应 body 实现 Default。造 500 的是 ResponseForPanic,它自己决定 body 类型;为了让 500 和正常响应能放进同一个类型,Service 实现把响应统一成:
type Response = Response<UnsyncBoxBody<Bytes, BoxError>>;
每个正常响应也要装箱一次,body 的 Data 必须是 Bytes、错误必须能转成 BoxError。这是 catch-panic 这个 feature 会把 body 模块和 http-body-util 拉进来的原因。
AssertUnwindSafe 出现了两次:一次包 || self.inner.call(req),一次包 future。它告诉编译器「panic 之后这些状态还能用」,这是 CatchPanic 替你做的保证,并不由它验证。内层服务在 call 里 panic 之后,self.inner 还留在 CatchPanic 里,下一个请求照样调它;如果 panic 打断了内层某个状态的更新,后面的请求看到的就是那个半截的状态。
「只认 String 和 &str」
catch_unwind 拿到的 panic 信息是 Box<dyn Any + Send>。panic!("literal") 的载荷是 &'static str,panic!("{}", x) 是 String,std::panic::panic_any(v) 可以是任何类型。默认的 DefaultResponseForPanic 只试前两种:
if let Some(s) = err.downcast_ref::<String>() {
tracing::error!("Service panicked: {}", s);
} else if let Some(s) = err.downcast_ref::<&str>() {
tracing::error!("Service panicked: {}", s);
} else {
tracing::error!(
"Service panicked but `CatchPanic` was unable to downcast the panic info"
);
};
let mut res = Response::new(Full::new(http_body_util::Full::from("Service panicked")));
*res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
这里还有一处文档和代码不一致:DefaultResponseForPanic 的文档注释(第 327 行)说返回「a 500 Internal Server error response with an empty body」,代码里的 body 是 16 字节的 Service panicked,还带着 Content-Type: text/plain; charset=utf-8。
要拿到别的载荷,就换掉处理器。ResponseForPanic 要求 Clone,给 FnMut(Box<dyn Any + Send>) -> Response<B> + Clone 的闭包都实现了,CatchPanicLayer::custom(闭包) 即可。
下面三条路径,panic 分别发生在同步的 call 里、future 里(String 载荷)、future 里(u32 载荷),再用自定义处理器把 u32 读出来:
tower-http = { version = "=0.7.1", features = ["catch-panic"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
http = "1"
http-body-util = "0.1"
bytes = "1"
tracing-subscriber = "0.3"
use bytes::Bytes;
use http::{Request, Response, StatusCode};
use http_body_util::{BodyExt, Full};
use std::{any::Any, convert::Infallible, future::Future, pin::Pin};
use tower::{service_fn, Layer, ServiceExt};
use tower_http::catch_panic::CatchPanicLayer;
type Fut = Pin<Box<dyn Future<Output = Result<Response<Full<Bytes>>, Infallible>> + Send>>;
// 按路径决定在哪里 panic、panic 什么
fn handler(req: Request<Full<Bytes>>) -> Fut {
match req.uri().path() {
// call 本身(同步部分)panic,还没返回 future
"/in-call" => panic!("panic in call"),
path => {
let path = path.to_owned();
Box::pin(async move {
match path.as_str() {
"/string" => panic!("user {} not found", 42),
"/any" => std::panic::panic_any(42_u32),
_ => Ok(Response::new(Full::from("ok"))),
}
})
}
}
}
async fn hit<L>(layer: &L, path: &str)
where
L: Layer<tower::util::ServiceFn<fn(Request<Full<Bytes>>) -> Fut>>,
L::Service: tower::Service<Request<Full<Bytes>>, Response = Response<tower_http::body::UnsyncBoxBody<Bytes, tower::BoxError>>, Error = Infallible>,
{
let svc = layer.layer(service_fn(handler as fn(_) -> _));
let res = svc.oneshot(Request::get(path).body(Full::default()).unwrap()).await.unwrap();
let status = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();
println!(" {path:<9} -> {status} {body:?}");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt().without_time().with_target(false).with_ansi(false).init();
std::panic::set_hook(Box::new(|_| {})); // 不让默认 hook 打印 panic 信息,只看 CatchPanic 自己的日志
println!("default handler");
let layer = CatchPanicLayer::new();
for path in ["/in-call", "/string", "/any"] {
hit(&layer, path).await;
}
println!("custom handler");
let custom = CatchPanicLayer::custom(|err: Box<dyn Any + Send>| {
let detail = err.downcast_ref::<u32>().map(|n| format!("code {n}")).unwrap_or_else(|| "unknown".into());
let mut res = Response::new(Full::from(detail));
*res.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
res
});
hit(&custom, "/any").await;
}
输出(M2 Max、rustc 1.99.0,跑 3 次相同):
default handler
ERROR Service panicked: panic in call
/in-call -> 500 Internal Server Error b"Service panicked"
ERROR Service panicked: user 42 not found
/string -> 500 Internal Server Error b"Service panicked"
ERROR Service panicked but `CatchPanic` was unable to downcast the panic info
/any -> 500 Internal Server Error b"Service panicked"
custom handler
/any -> 503 Service Unavailable b"code 42"
/in-call:service_fn在call里同步调用handler,handler在返回 future 之前就 panic 了,被第一次catch_unwind接住,走Kind::Panicked。axum 的 handler 都是async fn,panic 一定发生在 future 里,碰不到这条路;手写Service或service_fn时才会。/string:格式化过的 panic 信息是String,日志里能看到原文。/any:载荷是u32,默认处理器只能说「unable to downcast」;自定义处理器把它读成了code 42。
panic hook 在 catch_unwind 之前运行,所以默认情况下 panic 信息会被标准库的 hook 打印到 stderr 一次,CatchPanic 再记一次 ERROR 日志。上面为了只看 CatchPanic 自己的日志,把 hook 换成了空的。
问: 我的 release 配置里写了 panic = "abort",加了 CatchPanicLayer 还会 500 吗?
答: 不会。panic = "abort" 时 panic 直接终止进程,不展开栈,catch_unwind 没有东西可接,整个进程就退出了。CatchPanic 只在默认的 panic = "unwind" 下有用。反过来,如果服务确实需要「一个请求 panic 不影响别的请求」,就不能开 abort。
小结
CatchPanic是一个短路模块:Kind::Panicked装call时的 panic,Kind::Future用catch_unwind包住响应 future;body 里的 panic 不管。- 它不要求 body 实现
Default,代价是每个响应都被擦成UnsyncBoxBody<Bytes, BoxError>,body 的Data必须是Bytes。 AssertUnwindSafe是它替你做的保证;内层 panic 后仍会被继续使用。- 默认处理器只认
String和&str载荷,返回 500 和正文Service panicked(文档注释说是空 body,与代码不符);CatchPanicLayer::custom可以读别的载荷、换状态码。 panic = "abort"时它不起作用。