Rust in Everything
基于 tower-http 0.7.1

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 换成了空的。

📝NOTE
没有蠢问题

问: 我的 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" 时它不起作用。

💬 关联讨论

发起讨论
加载中...