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 | 用在 |
|---|---|---|---|
ServerErrorsAsFailures | 5xx | NeverClassifyEos | TraceLayer::new_for_http |
StatusInRangeAsFailures | 状态码在给定范围内 | NeverClassifyEos | 客户端(new_for_client_and_server_errors 是 400 到 599) |
GrpcErrorsAsFailures | grpc-status 不在成功集合里 | GrpcEosErrorsAsFailures | TraceLayer::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:?}");
}
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 发布。
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在用,重试的例子只在测试里。