自定义重试把 POST 发了三遍
主线位置:请求主线上的重试层。它在 tower 栈里最靠近 hyper 的位置:
FollowRedirect→CookieService→Retry→HyperService。分类器、每请求上限、预算这些机制见 重试。本页讲给它加一个「503 就重试」的分类器之后,最容易出的三个问题。
sequenceDiagram participant A as 调用方 participant C as CookieService participant R as Retry 层 participant S as 服务器 A->>C: POST orders C->>R: 请求 R->>S: 第 1 次 S-->>R: 503 已写入 R->>S: 第 2 次 S-->>R: 503 已写入 R->>S: 第 3 次 S-->>R: 503 已写入 R-->>C: 最后一个 503 C-->>A: Ok 503
实验 crate:exp-reqwest/pitb-lab(src/bin/retrypost.rs)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,模板 feature(含 cookies),release 构建,客户端 .no_proxy()。服务器是同一个 crate 里的 hyper 服务器,每个请求打一行日志(序号、方法、路径、收到的 Cookie、回的状态码):POST /orders 先把订单计数加一,再回 503,模拟「后端写成功了,前面的网关超时」;POST /login 第一次回 503 并带 Set-Cookie: lb=node-2(负载均衡的粘滞 cookie),第二次回 200 并带 Set-Cookie: session=abc。
现象
上游偶尔回 503,于是照着文档写了一个分类器:
let naive = retry::for_host("127.0.0.1").classify_fn(|rr| {
if rr.status() == Some(StatusCode::SERVICE_UNAVAILABLE) {
rr.retryable()
} else {
rr.success()
}
});
let c = Client::builder().no_proxy().retry(naive).cookie_store(true).build().unwrap();
然后下了一个单:
== POST /orders with the 503 classifier
server #1: POST /orders cookie=- -> 503
server #2: POST /orders cookie=- -> 503
server #3: POST /orders cookie=- -> 503
send() -> Ok(503)
orders written: 3
error_for_status() -> HTTP status server error (503 Service Unavailable) for url (http://127.0.0.1:54131/orders)
- POST 被发了 3 次(原始 1 次加默认上限 2 次重试),中间没有任何间隔。每次后端都写成功了,订单表里多了 3 条。
- 重试用完,
send()返回的是Ok,里面是最后一个 503。let r = c.post(..).send().await?;这一行顺利通过,失败要靠后面的error_for_status()才看得出来。
再登录一次,紧接着访问一个需要 cookie 的页面:
== POST /login, then GET /me
server #4: POST /login cookie=- -> 503
server #5: POST /login cookie=- -> 200
login -> 200 OK
server #6: GET /me cookie=session=abc -> 200
- 第一个响应上的
Set-Cookie: lb=node-2不见了。 下一个请求只带了session=abc。如果负载均衡靠这个 cookie 把后续请求钉在同一个节点上,钉住就失效了。
原因
分类器只看你让它看的东西。 ReqRep 提供 method()、uri()、status()、error(),reqwest 不替你按方法过滤。默认分类器之所以对 POST 也安全,是因为它只重试 HTTP/2 的 REFUSED_STREAM 和 GOAWAY,这两种情况下服务器保证没有处理过这个流;状态码类的重试是你自己加的,503 不代表服务器没处理过。for_host 经 Builder::scoped 建出的策略,每个请求的上限是 max_retries_per_request: 2, // on top of the original,重试之间没有退避。
最后一个结果原样交回。 tower 的 Retry 在策略说「不再重试」时,把最后一次的结果交给上一层;503 是一个 Ok(Response),于是 send() 也是 Ok。状态码从来不会自动变成错误,这一点和没开重试时一样(见 error_for_status)。
cookie 层在重试层外面。 build() 里栈是这样拼的(Retry::new):
let svc = tower::retry::Retry::new(retry_policy.clone(), hyper_service);
#[cfg(feature = "cookies")]
let svc = CookieService::new(svc, config.cookie_store.clone());
let hyper = FollowRedirect::with_policy(svc, redirect_policy.clone());
CookieService 只看见 Retry 交回来的最终响应,被重试掉的那些响应在 Retry 内部就被丢了,上面的 Set-Cookie 根本没到 cookie store。这个层次关系的实测见 cookie。
修法
分类器先看方法。 只重试幂等方法(GET、HEAD、PUT、DELETE、OPTIONS、TRACE),http::Method::is_idempotent() 正好是这个集合:
let fixed = retry::for_host("127.0.0.1").classify_fn(|rr| {
if rr.method().is_idempotent() && rr.status() == Some(StatusCode::SERVICE_UNAVAILABLE) {
rr.retryable()
} else {
rr.success()
}
});
== fixed classifier: only idempotent methods
server #7: POST /orders cookie=- -> 503
POST -> 503 Service Unavailable, orders written: 1
server #8: GET /orders cookie=- -> 503
server #9: GET /orders cookie=- -> 503
server #10: GET /orders cookie=- -> 503
GET -> 503 Service Unavailable
server #11: POST /login cookie=- -> 503
login -> 503 Service Unavailable
server #12: GET /me cookie=lb=node-2 -> 200
POST 只发一次,GET 照常重试。最后三行是同一个修好的客户端(也开了 cookie_store(true))重新登录:POST 不再被重试,503 交回调用方,它的 Set-Cookie: lb=node-2 经过 CookieService 存了进去,下一个请求带上了它。POST 真要重试,前提是服务端支持幂等键(同一个键的请求只处理一次);分类器拿不到请求头,没法按「带没带幂等键」区分,这种情况最好在业务代码里自己重试,而不是交给 Client。
send() 之后接 error_for_status()。 重试用完的 503 才会变成 Err,上面第一段输出的最后一行就是它。
需要每个响应上的 cookie,就别在 Client 上重试那个请求。 上面的登录接口就是这样:不重试,503 交回调用方,cookie 存进去了,再由业务代码决定是否重发。不按方法区分的接口,可以在分类器里用 rr.uri() 按路径排除。
怎么检查
- 服务端看同一个请求在几毫秒内连着来了 3 次(没有退避),就是
Client层的重试。 - 代码里搜
classify_fn/classify,看分类器里有没有method()。 - 搜
send().await?后面没有error_for_status的地方:开了状态码重试之后,它们拿到的Ok可能是重试用完的失败。
小结
- 自己写的状态码分类器不看方法,POST 也会被重发;默认上限 2 次重试、没有退避,一个 POST 最多到达服务器 3 次。
- 重试用完返回的是最后一个响应,
Ok(503),要error_for_status()才变成错误。 CookieService在Retry外面,被重试掉的响应上的Set-Cookie不进 cookie store。- 修法:分类器里先判断
rr.method().is_idempotent();send()后接error_for_status();需要每个响应副作用的请求不交给Client重试。