Rust in Everything
基于 tower-http 0.7.1

classify:什么算失败

主线位置:结局主线的第一问「什么算失败」。作用点上横跨两个时刻:响应 future 完成时看响应头,能下结论就当场下(Ready);不能就把判断推到 body 流动时,等 trailers(RequiresEos)。

stateDiagram-v2
  [*] --> MakeClassifier: 收到请求
  MakeClassifier --> ClassifyResponse: make_classifier(&req)
  ClassifyResponse --> Ready: classify_response 看响应头
  ClassifyResponse --> ClassifyEos: RequiresEos
  ClassifyResponse --> Failed: classify_error 内层返回 Err
  ClassifyEos --> Ready: classify_eos 看 trailers
  ClassifyEos --> Failed: classify_error body 出错
  Ready --> [*]
  Failed --> [*]

三个 trait

「成败」被拆成三步,每一步一个 trait,都在 classify/mod.rs:

pub trait MakeClassifier {
    type Classifier: ClassifyResponse<
        FailureClass = Self::FailureClass,
        ClassifyEos = Self::ClassifyEos,
    >;
    type FailureClass;
    type ClassifyEos: ClassifyEos<FailureClass = Self::FailureClass>;

    fn make_classifier<B>(&self, req: &Request<B>) -> Self::Classifier;
}

pub trait ClassifyResponse {
    type FailureClass;
    type ClassifyEos: ClassifyEos<FailureClass = Self::FailureClass>;

    fn classify_response<B>(
        self,
        res: &Response<B>,
    ) -> ClassifiedResponse<Self::FailureClass, Self::ClassifyEos>;

    fn classify_error<E>(self, error: &E) -> Self::FailureClass
    where
        E: fmt::Display + 'static;
    // ...
}

pub trait ClassifyEos {
    type FailureClass;

    fn classify_eos(self, trailers: Option<&HeaderMap>) -> Result<(), Self::FailureClass>;

    fn classify_error<E>(self, error: &E) -> Self::FailureClass
    where
        E: fmt::Display + 'static;
    // ...
}
  • MakeClassifier 每个请求调一次,拿到请求的引用,可以按方法、URI 造不同的分类器。
  • ClassifyResponse 的两个方法都取 self:一个分类器只用一次,要么分类响应头,要么分类内层的错误。
  • ClassifyEos 同样取 self,在 body 结束时拿到 trailers(可能没有,所以是 Option),或者在 body 出错时分类错误。

classify_response 的返回值决定判断推到哪个时刻:

pub enum ClassifiedResponse<FailureClass, ClassifyEos> {
    /// The response was able to be classified immediately.
    Ready(Result<(), FailureClass>),
    /// We have to wait until the end of a streaming response to classify it.
    RequiresEos(ClassifyEos),
}

FailureClass 是分类器自己定的类型,不是一个固定的错误枚举。它可以只是一个状态码,也可以带上「能不能重试」之类的信息;classify_error 的文档举的就是这个例子:「A retry policy might allow retrying some errors and not others.」

两个辅助类型:

  • NeverClassifyEos<T> 里有一个 Infallible 字段,根本造不出来。一个分类器把 ClassifyEos 设成它,就是在类型上声明「我永远返回 Ready」,它的两个方法体都是 unreachable!()。
  • SharedClassifier<C> 把一个不看请求的 ClassifyResponse + Clone 变成 MakeClassifier:每个请求 clone 一份。内置分类器的 make_classifier() / into_make_classifier() 返回的都是它。

四个内置分类器

分类器失败的条件ClassifyEos用在
ServerErrorsAsFailures5xxNeverClassifyEosTraceLayer::new_for_http
StatusInRangeAsFailures状态码在给定范围内NeverClassifyEos客户端(new_for_client_and_server_errors 是 400 到 599)
GrpcErrorsAsFailuresgrpc-status 不在成功集合里GrpcEosErrorsAsFailuresTraceLayer::new_for_grpc
MapFailureClass和内层一样,只换 FailureClass内层的包一层.map_failure_class(f)

服务端只把 5xx 算失败,4xx 是客户端的错,不算服务的故障;客户端反过来,自己发出去的请求拿到 404 也是失败,所以 crate 文档的客户端示例用 StatusInRangeAsFailures::new(400..=599)。

gRPC 不一样。gRPC 的 HTTP 状态码几乎总是 200,结果写在 grpc-status 里,可能在响应头里(「trailers-only」响应,出错时常见),更常见的是在 body 末尾的 trailers 里。GrpcErrorsAsFailures::classify_response 只看头:

        match classify_grpc_metadata(res.headers(), self.success_codes) {
            ParsedGrpcStatus::Success | ParsedGrpcStatus::HeaderNotGrpcCode => {
                ClassifiedResponse::Ready(Ok(()))
            }
            ParsedGrpcStatus::NonSuccess(status) => {
                ClassifiedResponse::Ready(Err(GrpcFailureClass::Status(status)))
            }
            ParsedGrpcStatus::GrpcStatusHeaderMissing => {
                ClassifiedResponse::RequiresEos(GrpcEosErrorsAsFailures {
                    success_codes: self.success_codes,
                })
            }
        }

头里有 grpc-status 就当场下结论,没有就 RequiresEos,交给 GrpcEosErrorsAsFailures::classify_eos 去看 trailers。「成功集合」是一个 17 位的位掩码(bitflags),默认只有 Ok,with_success(GrpcCode::NotFound) 可以把别的 code 也算成功。grpc-status 的值不是整数时(HeaderNotGrpcCode),源码注释说「this is treated as Success but kept separate for clarity」。

0.7.0 的 #422 让失败信息带上 grpc-message:classify_grpc_metadata 把它百分号解码后放进新的 GrpcStatus,同时 GrpcCode 和 GrpcFailureClass 加上了 #[non_exhaustive],这是 0.7.0 的破坏性修改之一。

实测

classify 不挂在任何 feature 后面,只依赖 tower-http 本身就能直接调:

tower-http = "=0.7.1"
http = "1"
use http::{HeaderMap, HeaderValue, Response, StatusCode};
use tower_http::classify::{
  ClassifiedResponse, ClassifyEos, ClassifyResponse, GrpcCode, GrpcErrorsAsFailures,
  ServerErrorsAsFailures, StatusInRangeAsFailures,
};

fn res(status: u16, headers: &[(&'static str, &'static str)]) -> Response<()> {
  let mut r = Response::new(());
  *r.status_mut() = StatusCode::from_u16(status).unwrap();
  for (k, v) in headers {
    r.headers_mut().insert(*k, HeaderValue::from_static(v));
  }
  r
}

fn show<C: ClassifyResponse>(name: &str, c: C, r: &Response<()>, trailers: Option<&HeaderMap>)
where
  C::FailureClass: std::fmt::Display,
{
  match c.classify_response(r) {
    ClassifiedResponse::Ready(Ok(())) => println!("{name}: Ready(Ok)"),
    ClassifiedResponse::Ready(Err(f)) => println!("{name}: Ready(Err: {f})"),
    ClassifiedResponse::RequiresEos(eos) => match eos.classify_eos(trailers) {
      Ok(()) => println!("{name}: RequiresEos -> Ok"),
      Err(f) => println!("{name}: RequiresEos -> Err: {f}"),
    },
  }
}

fn main() {
  // 纯 HTTP 状态码
  show("server 404", ServerErrorsAsFailures::new(), &res(404, &[]), None);
  show("server 503", ServerErrorsAsFailures::new(), &res(503, &[]), None);
  show("range  404", StatusInRangeAsFailures::new_for_client_and_server_errors(), &res(404, &[]), None);

  // gRPC:HTTP 状态码一律 200,结果在 grpc-status 里
  let grpc = GrpcErrorsAsFailures::new();
  show("grpc trailers-only", grpc.clone(), &res(200, &[("grpc-status", "5"), ("grpc-message", "no%20such%20user")]), None);
  let mut trailers = HeaderMap::new();
  trailers.insert("grpc-status", HeaderValue::from_static("13"));
  show("grpc streaming", grpc.clone(), &res(200, &[("content-type", "application/grpc")]), Some(&trailers));
  show("grpc streaming, no trailers", grpc.clone(), &res(200, &[]), None);
  show("grpc 500 without grpc-status", grpc.clone(), &res(500, &[]), None);
  show("grpc NotFound as success", grpc.with_success(GrpcCode::NotFound), &res(200, &[("grpc-status", "5")]), None);

  // map_failure_class:把失败类型换成自己的
  let mapped = ServerErrorsAsFailures::new().map_failure_class(|f| format!("[mapped] {f}"));
  show("mapped 502", mapped, &res(502, &[]), None);

  // 构造时校验范围端点
  std::panic::set_hook(Box::new(|_| {}));
  let r = std::panic::catch_unwind(|| StatusInRangeAsFailures::new(0..=599));
  let msg = r.err().and_then(|p| p.downcast_ref::<&str>().map(|s| s.to_string()));
  println!("new(0..=599) panicked: {msg:?}");
}
❗IMPORTANT
动脑筋:grpc 500 without grpc-status 那一行会打印什么?

一个 HTTP 500 响应,头里没有 grpc-status,body 结束时也没有 trailers,交给默认的 GrpcErrorsAsFailures。平台 M2 Max、rustc 1.99.0。

前面几行的结果没什么意外:ServerErrorsAsFailures 放过 404、抓住 503;StatusInRangeAsFailures 的 400 到 599 抓住 404;trailers-only 的 grpc-status: 5 当场判失败,消息里是解码后的 no such user;流式响应要等 trailers,grpc-status: 13 判失败;没有 trailers 判成功;把 NotFound 加进成功集合后,同一个 grpc-status: 5 就是 Ready(Ok)。

StatusInRangeAsFailures::new 会在构造时检查范围两端是不是合法状态码(StatusCode::from_u16 只接受 100 到 999),0..=599 的起点不合法,直接 panic,消息是「range start isn't a valid status code」。

动脑筋答案

server 404: Ready(Ok)
server 503: Ready(Err: Status code: 503 Service Unavailable)
range  404: Ready(Err: Status code: 404 Not Found)
grpc trailers-only: Ready(Err: Status: NotFound: no such user)
grpc streaming: RequiresEos -> Err: Status: Internal
grpc streaming, no trailers: RequiresEos -> Ok
grpc 500 without grpc-status: RequiresEos -> Ok
grpc NotFound as success: Ready(Ok)
mapped 502: Ready(Err: [mapped] Status code: 502 Bad Gateway)
new(0..=599) panicked: Some("range start isn't a valid status code")

跑 3 次相同。HTTP 500 被判成成功。GrpcErrorsAsFailures 从头到尾不看 HTTP 状态码:头里没有 grpc-status,就当它是还没结束的流式响应,返回 RequiresEos;body 结束时 trailers 是 None,classify_eos 直接 Ok(())。代理、负载均衡器在 gRPC 服务前面回一个 502 / 503 时,TraceLayer::new_for_grpc 不会记 on_failure。gRPC 的文档给过一张「响应里没有 grpc-status 时,HTTP 状态码对应哪个 gRPC code」的映射表(比如 503 对应 UNAVAILABLE),这个分类器没有用它。需要的话,用一个自己的 ClassifyResponse 先看状态码、再委托给 GrpcErrorsAsFailures。

为什么独立成一个模块

MakeClassifier::FailureClass 和 classify_error 的注释都提到了重试(「whether or not it should be considered retryable」「A retry policy might allow retrying some errors and not others」):一个分类器可以既给 Trace 判断要不要记失败,又给重试策略判断要不要重试。但在 crate 里,真正消费 ClassifyResponse 的只有 trace;on_early_drop 的 EarlyDropsAsFailures 借的是 trace::OnFailure,不经过分类器。「也能用于重试」对应的代码,是 classify/mod.rs 末尾的一个 #[cfg(test)] mod usable_for_retries,里面给 tower 的 retry::Policy 写了一个基于分类结果的实现,注释是「Just verify that we can actually use this response classifier to determine retries as well」。它只证明类型上走得通,没有作为公开 API 发布。

📝NOTE
冷知识:先有 classify,后有 Trace

classify 是 2021 年 3 月的 #49(「Add response classification」)加进来的,比 Trace(#77,2021 年 4 月)早一个多月。#49 的提交记录里,第一条是「Add response classification and tracing middleware」,倒数几条里有一条「Remove temporary tracing middleware」:分类器和一个临时的 tracing 中间件一起写、一起评审,合入前把临时中间件删了,只留下分类器。同一个 PR 里还有「Add example retry policy based on response classifier」和「Rename ClassifiedNowOrLater to ClassifiedResponse」,后者就是 Ready / RequiresEos 这个枚举现在的名字。所以 classify 一开始就是按「多个消费者共用」设计的,只是第二个消费者一直没有出现在 crate 里。

小结

  • 成败判断拆成三个 trait:MakeClassifier 每个请求造一个分类器,ClassifyResponse 看响应头,ClassifyEos 看 trailers;后两个取 self,只用一次。
  • Ready 当场下结论,RequiresEos 把判断推到 body 结束;NeverClassifyEos 在类型上声明永远 Ready。
  • 服务端用 ServerErrorsAsFailures(只算 5xx),客户端用 StatusInRangeAsFailures(范围端点非法时构造即 panic),gRPC 用 GrpcErrorsAsFailures(位掩码,0.7.0 起带上解码后的 grpc-message)。
  • GrpcErrorsAsFailures 不看 HTTP 状态码:没有 grpc-status 的 500 被判成功。
  • 模块不受 feature 控制,设计时考虑了重试,但 crate 里只有 trace 在用,重试的例子只在测试里。

💬 关联讨论

发起讨论
加载中...