重试
主线位置:请求主线上最里面的一层。
FollowRedirect和CookieService之下、HyperService之上是 tower 的Retry,它拿到 hyper-util 的结果后问 reqwest 的retry::Policy要不要再发一次。重定向的每一跳各自经过它;整个Client共用一个重试预算。
flowchart TB
res["一次尝试的结果"] --> cls{"分类器"}
cls -- Success --> dep["预算存入"]
dep --> out["交给上层"]
cls -- Retryable --> bud{"预算取得出"}
bud -- 否 --> out
bud -- 是 --> cnt["retry_cnt 加 1"]
cnt --> have{"手里有克隆"}
have -- 否 --> out
have -- 是 --> send["立即重发"]
send --> clone{"再克隆一份"}
clone --> res
实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,卷里模板的 feature,release 构建。源站是 exp-reqwest/policy-lab 里的 hyper 服务器:/s/503 永远回 503,/fail/K/503 前 K 次回 503 之后回 200,/abort 不回响应直接断开连接(hyper 把它报成 IncompleteMessage);服务端数收到的请求个数。HTTP/2 的实验用 h2 crate 直接写了一个 h2c 服务器,前 N 个流回 RST_STREAM(REFUSED_STREAM)。客户端一律 .no_proxy()。
不是 0.13 才有
reqwest::retry 模块和 ClientBuilder::retry 来自 #2763(acd1b05,「feat: add reqwest::retry policies」),首次发布在 v0.12.23。0.12.28 和 0.13.5 的 retry.rs 是同一套设计。模块里有两处注释写着「sealed types and traits on purpose while exploring design space」:scope::Scope 和 classify::Classify 两个 trait 放在私有子模块里,外面没法实现;能用的入口只有 for_host、never 和闭包形式的 classify_fn。
build 里,重试层包在 HyperService 外面、cookie 层里面:
let retry_policy = config.retry_policy.into_policy();
let svc = tower::retry::Retry::new(retry_policy.clone(), hyper_service);
Retry 和 Policy trait 本身(retry 决定要不要重试,clone_request 备一份请求)见 tower 卷 Policy 与 ResponseFuture,下层版本同为 tower 0.5.3。这里只看 reqwest 的那一半。
两套默认:Client 默认和 Builder 默认
用户拿到的 Builder 都从 Builder::scoped 来:
pub fn scoped(scope: impl scope::Scope) -> Self {
Self {
budget: Some(0.2),
classifier: classify::Classifier::Never,
max_retries_per_request: 2, // on top of the original
scope: scope::Scoped::Dyn(Arc::new(scope)),
}
}
Client 不调 .retry() 时用的是另一个 crate 内部的 Builder::default:
pub(crate) fn default() -> Builder {
Self {
// unscoped protocols nacks doesn't need a budget
budget: None,
classifier: classify::Classifier::ProtocolNacks,
max_retries_per_request: 2, // on top of the original
scope: scope::Scoped::Unscoped,
}
}
Client 默认 | for_host(h) | never() | |
|---|---|---|---|
| 分类器 | ProtocolNacks | Never,要自己 classify_fn | Never |
| 作用域 | 所有请求 | host 等于 h 的请求 | 没有请求 |
| 预算 | 无 | 20% | 无 |
| 每请求上限 | 2 次重试 | 2 次重试 | 2 次重试 |
.retry(x) 整个替换默认值,不是叠加。for_host("api.example.com") 不配分类器,等于什么都不重试,连默认的协议级重试也一起关掉了。模块文档说得很清楚:「providing a specific retry policy will override the default, and you will need to explicitly include that behavior」,但分类器 API 是封住的,ProtocolNacks 那个判断用户写不出来,只能在 classify_fn 里自己 downcast 出 h2::Error。
默认重试什么:is_retryable_error
ProtocolNacks 只在请求出错(没拿到响应)时调 is_retryable_error:
fn is_retryable_error(err: &crate::Error) -> bool {
use std::error::Error as _;
// pop the reqwest::Error
let err = if let Some(err) = err.source() {
err
} else {
return false;
};
// pop the legacy::Error
let err = if let Some(err) = err.source() {
err
} else {
return false;
};
// ...
#[cfg(feature = "http2")]
if let Some(cause) = err.source() {
if let Some(err) = cause.downcast_ref::<h2::Error>() {
// They sent us a graceful shutdown, try with a new connection!
if err.is_go_away() && err.is_remote() && err.reason() == Some(h2::Reason::NO_ERROR) {
return true;
}
// REFUSED_STREAM was sent from the server, which is safe to retry.
// https://www.rfc-editor.org/rfc/rfc9113.html#section-8.7-3.2
if err.is_reset() && err.is_remote() && err.reason() == Some(h2::Reason::REFUSED_STREAM)
{
return true;
}
}
}
false
}
剥三层:reqwest::Error → hyper-util 的 legacy::Error → hyper::Error → 底下的 h2::Error。只认两种:对端发来的 GOAWAY(NO_ERROR)(优雅关闭,后面的流对方没处理)和对端发来的 RST_STREAM(REFUSED_STREAM)。打开 http3 时,h3 的连接错误只认字符串等于 "timeout" 的那种。HTTP/1.1 上的错误一个都不认;HTTP/1.1 连接在池里被对端关掉、请求还没写出去的情形,由 hyper-util 自己的 retry_canceled_requests 处理,reqwest 保持它的默认 true(见连接池与 HTTP 版本)。状态码永远不在默认重试之列,503、429 都原样返回。
这两种错误都意味着服务端没有处理这个流(RFC 9113 第 8.7 节),所以默认分类器不看方法:POST 一样重发。实测(retry h2):
## default, GET, 1 refused
[h2] stream #1 GET /x
[h2] stream #2 GET /x
Ok 200 OK HTTP/2.0
## default, POST bytes, 1 refused
[h2] stream #1 POST /x
[h2] stream #2 POST /x
Ok 200 OK HTTP/2.0
## default, POST stream, 1 refused
[h2] stream #1 POST /x
Err reqwest::Error { kind: Request, url: "http://127.0.0.1:62426/x", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Http2, Error { kind: Reset(StreamId(1), REFUSED_STREAM, Remote) })) }
## default, GET, 5 refused
[h2] stream #1 GET /x
[h2] stream #2 GET /x
[h2] stream #3 GET /x
Err reqwest::Error { kind: Request, url: "http://127.0.0.1:62428/x", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Http2, Error { kind: Reset(StreamId(5), REFUSED_STREAM, Remote) })) }
## retry::never(), GET, 1 refused
[h2] stream #1 GET /x
Err reqwest::Error { kind: Request, url: "http://127.0.0.1:62430/x", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Http2, Error { kind: Reset(StreamId(1), REFUSED_STREAM, Remote) })) }
- 被拒一次:GET 和 POST(
Bytesbody)都重发了,第二个流成功。 - 流式 body 不重发,直接报错(下一节讲原因)。
- 一直被拒:一共 3 个流(1 次原始 + 2 次重试),第 3 个的错误交给调用方;
StreamId(5)是 HTTP/2 第 3 个客户端流的编号。 retry::never()关掉了这个默认行为。
同一个客户端在 HTTP/1.1 上遇到 /abort,只发 1 次就报错:
default, GET 503 attempts=1 Ok 503 Service Unavailable (12.3ms)
default, GET abort attempts=1 Err reqwest::Error { kind: Request, url: "http://127.0.0.1:62393/abort", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(IncompleteMessage)) } (5.0ms)
...
GOAWAY 的重试来自 a03ca50(「Fix HTTP/2: retry requests rejected by a graceful shutdown」,没有 PR 编号),首次发布在 v0.11.9;REFUSED_STREAM 来自 #2081(4ab5fb0),首次发布在 v0.11.24。那时没有 tower 层:a03ca50 往 PendingRequest 里加了一个 retry_count 字段和一个 retry_error 方法,poll 拿到错误时先问 is_retryable_error,body 不可复用就 debug!("error was retryable, but body not reusable") 放弃,retry_count >= 2 就停,否则用保存的方法、URI、头和 body 拼一个新请求再发。#2763(v0.12.23)把这段逻辑搬进 retry.rs:判断变成 ProtocolNacks 分类器,「body 不可复用」变成 clone_request 里的 try_clone()?,「最多 2 次」变成 max_retries_per_request: 2,循环交给 tower 的 Retry。默认行为没变,只是换成了用户也能配置的形状。
一次重试怎么发生:retry 和 clone_request
reqwest 的 Policy 实现了 tower 的 retry::Policy。retry 在每次尝试的结果出来后被调用:
// TODO? backoff futures...
type Future = std::future::Ready<()>;
fn retry(
&mut self,
req: &mut Req,
result: &mut crate::Result<http::Response<B>>,
) -> Option<Self::Future> {
match self.classifier.classify(req, result) {
classify::Action::Success => {
log::trace!("shouldn't retry!");
if let Some(ref budget) = self.budget {
budget.deposit();
}
None
}
classify::Action::Retryable => {
log::trace!("could retry!");
if self.budget.as_ref().map(|b| b.withdraw()).unwrap_or(true) {
self.retry_cnt += 1;
Some(std::future::ready(()))
} else {
log::debug!("retryable but could not withdraw from budget");
None
}
}
}
}
Future 是 Ready<()>:没有退避,判定可重试后立刻重发(上面的 TODO 说明这是有意留空)。下面的实验里 3 次尝试一共只花了几毫秒。clone_request 在每次发出前被调用,备一份下次重发用:
fn clone_request(&mut self, req: &Req) -> Option<Req> {
if self.retry_cnt > 0 && !self.scope.applies_to(req) {
return None;
}
if self.retry_cnt >= self.max_retries_per_request {
log::trace!("max_retries_per_request hit");
return None;
}
let body = req.body().try_clone()?;
let mut new = http::Request::new(body);
*new.method_mut() = req.method().clone();
*new.uri_mut() = req.uri().clone();
*new.version_mut() = req.version();
*new.headers_mut() = req.headers().clone();
*new.extensions_mut() = req.extensions().clone();
Some(new)
}
tower 的 Retry::call 先 clone_request 再发原请求;ResponseFuture 拿到结果后,手里有克隆才问 retry,retry 返回 Some 就把克隆发出去,同时再 clone_request 一份。三件事由此而来:
- 流式 body 不重试。
try_clone()对流式 body 返回None(和重定向用的是同一个函数),一开始就没有克隆,retry根本不会被调用,分类器说什么都没用。multipart 也是流式的。 - 上限的含义。
retry_cnt在retry里加 1,clone_request在retry_cnt >= max时不再备份;默认 2 意味着最多 3 次尝试。 - 作用域只拦第二次及以后的重试。 条件是
retry_cnt > 0 && !applies_to:第一次调用clone_request时retry_cnt是 0,作用域不看,克隆照备;分类器(它也不看 host)判可重试,这份克隆就发出去了。下面实验里for_host(localhost)的客户端请求127.0.0.1也多发了一次。
实测(retry status,分类器:状态码 503 判可重试,其余判成功):
...
for_host(127.0.0.1) no classifier, GET 503 attempts=1 Ok 503 Service Unavailable (2.0ms)
for_host + 503 retryable, GET 503 attempts=3 Ok 503 Service Unavailable (3.2ms)
for_host + 503 retryable, POST bytes attempts=3 Ok 503 Service Unavailable (1.8ms)
for_host + 503 retryable, POST stream attempts=1 Ok 503 Service Unavailable (2.1ms)
for_host + 503 retryable, GET fail-once attempts=2 Ok 200 OK (1.3ms)
max_retries_per_request(5), GET 503 attempts=6 Ok 503 Service Unavailable (8.1ms)
for_host(localhost), GET 127.0.0.1 503 attempts=2 Ok 503 Service Unavailable (7.9ms)
for_host(localhost), GET localhost 503 attempts=3 Ok 503 Service Unavailable (6.8ms)
for_host + any error retryable, GET abort attempts=3 Err reqwest::Error { kind: Request, url: "http://127.0.0.1:62393/abort", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(IncompleteMessage)) } (3.7ms)
for_host + any error retryable, POST abort attempts=3 Err reqwest::Error { kind: Request, url: "http://127.0.0.1:62393/abort", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(IncompleteMessage)) } (3.0ms)
- 重试用完,调用方拿到的是最后一次的结果:
Ok(503),不是错误。 - POST 照样重发 3 次。 reqwest 不替用户检查方法是否幂等,模块文档只是提醒「Requests should not be retried if the server cannot safely handle the same request twice」,判断要写进分类器(
ReqRep::method能拿到方法)。最后两行更危险:/abort在服务端已经读完请求体之后才断开,POST 被处理了 3 次。 - 作用域外的
127.0.0.1发了 2 次,作用域内的localhost发了 3 次。
retry::for_host("api.example.com").classify_fn(...),分类器对所有 503 判可重试,没有改上限。用这个客户端请求另一个主机 other.example.com,它一直回 503。服务端一共收到几个请求?平台:M2 Max、macOS 27,reqwest 0.13.5,明文 HTTP/1.1(实验里用 localhost 和 127.0.0.1 代替两个主机)。
预算:整个 Client 共用
into_policy 把百分比变成 tower 的 TpsBudget:
let budget = self
.budget
.map(|p| Arc::new(Budget::new(Duration::from_secs(10), 10, p)));
build 只调一次 into_policy,之后每个请求克隆的 Policy 共享同一个 Arc<Budget>,所以预算是整个 Client(包括它的克隆)共用的,不分 host。TpsBudget::new(10 秒, 10, 0.2) 正是 tower 的默认参数:成功一次存 1,重试一次取 5,另有一笔 10 × 10 × 5 = 500 的底数,等于 10 秒内不靠任何成功也能重试 100 次。窗口、底数和存取比例的推导见 tower 卷退避与预算。max_extra_load 改的是第三个参数,no_budget 去掉预算。
实测(retry budget):分类器同上,max_retries_per_request(1000) 让上限不起作用,同一个客户端连发 3 个请求到 /s/503:
default budget (0.2) attempts per request: [101, 1, 1]
default budget (0.2) after 20 successes: 5
max_extra_load(0.0) attempts per request: [101, 1, 1]
no_budget attempts per request: [1001, 1001, 1001]
- 默认预算:第一个请求把 100 次重试的底数用光,后两个请求一次也重试不了。
- 接着 20 个成功请求各存 1,凑出 20 / 5 = 4 次重试:下一个 503 请求发了 5 次。
max_extra_load(0.0)并不是「不重试」:比例为 0 时 tower 改成存 0 取 1,底数变成10 × 10 × 1 = 100,仍是 100 次。真要关掉用never()。- 没有预算时,每个请求都用满 1000 次重试。
max_retries_per_request文档说它和预算「in combination」:两者谁先到谁说了算。
never() 和 Client 默认都没有预算,默认分类器只认协议级拒绝,代码注释说这种重试「doesn't need a budget」。
和其他层的关系
- 重定向。 重试层在
FollowRedirect里面,每一跳各自重试,各自有max_retries_per_request次;全部共享一个预算。 - cookie。 重试层在
CookieService里面,被重试掉的那次响应上的Set-Cookie不会进 cookie store,重发的请求也沿用第一次的Cookie头,见 cookie。默认分类器只在没拿到响应时重试,不受影响;按状态码重试的自定义分类器才会碰到。 - 超时。 总超时在
PendingRequest里,包住所有尝试,见超时。
动脑筋答案
...
for_host(localhost), GET 127.0.0.1 503 attempts=2 Ok 503 Service Unavailable (7.9ms)
...
2 个。作用域只在 retry_cnt > 0 时才检查:Retry::call 第一次调 clone_request 时 retry_cnt 为 0,克隆照备;503 回来,分类器判可重试,预算也取得出,retry_cnt 变成 1,克隆被发出去;发出时再调 clone_request,这次作用域不匹配,返回 None,第二个 503 就是最终结果。这次多发的请求还从预算里取走了 5 个单位。作用域内的主机是 3 个。连跑 3 次结果相同。
小结
retry模块从 0.12.23 就有(#2763),0.13 没有改它的设计;分类器和作用域的 trait 是封住的,入口只有for_host、never、classify_fn。Client默认只重试 HTTP/2 对端的GOAWAY(NO_ERROR)和REFUSED_STREAM(h3 的timeout),不看方法,最多 2 次,没有预算;HTTP/1.1 错误和任何状态码都不重试。.retry(x)整个替换默认值;for_host不配分类器就什么都不重试。- 流式 body(含 multipart)一开始就克隆不了,永远不重试;
Bytesbody 的 POST 只要分类器说可以就重发,reqwest 不查幂等性。 - 没有退避,判定后立即重发;重试用完返回最后一次的结果,503 仍是
Ok。 - 预算整个
Client共用:10 秒内底数 100 次,成功 5 次攒 1 次;max_extra_load(0.0)仍有 100 次。 - 作用域只拦第二次及以后的重试,作用域外的请求也会被重发一次。