执行与 Pending
主线位置:请求主线的第二步。
RequestBuilder::send把Request交给Client::execute_request,后者补上默认头、把请求送进ClientBuilder::build时组装好的 tower 栈,返回一个Pending;.await这个Pending,请求才真正开始连接。栈的最底下是 hyper-util 的 legacyClient,再往下是连接器(连接主线,见 连接器)。
flowchart TB
send["send"] --> exec["execute_request"]
exec --> pend["Pending"]
exec --> dec["Decompression"]
dec --> fr["FollowRedirect"]
fr --> ck["CookieService"]
ck --> rt["tower Retry"]
rt --> hs["HyperService"]
hs --> legacy["legacy Client"]
legacy --> pool{"池里有连接"}
pool -->|有| wire["写请求"]
pool -->|没有| conn["Connector"]
conn --> layers["connector_layer"]
layers --> csvc["ConnectorService"]
csvc --> wire
文件是 async_impl/client.rs(3196 行)。这一页读其中三段:build 的最后 90 行(组装栈)、execute_request(约 80 行)、Pending(约 140 行)。build 前面那一千行配置 TLS、DNS、代理和连接池,见 一个 Client。实验在 exp-reqwest/req-lab:layers 用一个自己写的 HTTP/1.1 服务器(按路径回 302、503 或 200,打印每个请求的 cookie 和 accept-encoding),wire 打印完整请求头,size 量 future 的大小。所有客户端都调了 .no_proxy()(这台机器有系统代理 127.0.0.1:7890)。
execute_request 做了什么
execute_request 是同步函数,返回 Pending,前半段:
pub(super) fn execute_request(&self, req: Request) -> Pending {
let (method, url, mut headers, body, version, extensions) = req.pieces();
if url.scheme() != "http" && url.scheme() != "https" {
return Pending::new_err(error::url_bad_scheme(url));
}
// check if we're in https_only mode and check the scheme of the current URL
if self.inner.https_only && url.scheme() != "https" {
return Pending::new_err(error::url_bad_scheme(url));
}
// insert default headers in the request headers
// without overwriting already appended headers.
for (key, value) in &self.inner.headers {
if let Entry::Vacant(entry) = headers.entry(key) {
entry.insert(value.clone());
}
}
let uri = match try_uri(&url) {
Ok(uri) => uri,
_ => return Pending::new_err(error::url_invalid_uri(url)),
};
let body = body.unwrap_or_else(Body::empty);
self.proxy_auth(&uri, &mut headers);
self.proxy_custom_headers(&uri, &mut headers);
- scheme 不是
http/https,或者开了https_only却是http,返回带 URL 的Builder错误(请求构建 里ftp://那一行)。 - 默认头只填空位:请求已经有同名头,默认值整个不加。默认头来自
ClientBuilder::new,只有一个accept: */*;user_agent()、default_headers()往同一个HeaderMap里插。reqwest 默认不发User-Agent,wire打印的请求头里确实没有这一行。 Url转成http::Uri。try_uri就是url.as_str().parse(),Url能接受、Uri不接受的(比如主机名里有{),在这里变成url_invalid_uri。- 没有 body 就给一个空的
Body。 - 目标是
http://且走代理时,补Proxy-Authorization和代理的自定义头(https://的这些头放在 CONNECT 请求里),见 代理。
后半段把这些拼成 hyper::Request,交给栈:
let in_flight = match version {
#[cfg(feature = "http3")]
http::Version::HTTP_3 if self.inner.h3_client.is_some() => {
// ...
}
_ => {
let mut req = builder.body(body).expect("valid request parts");
*req.headers_mut() = headers.clone();
let mut hyper = self.inner.hyper.clone();
ResponseFuture::Default(hyper.call(req))
}
};
只有请求的版本是 HTTP_3、而且客户端配了 HTTP/3,才走 h3_client 那套栈;其余都走 hyper。headers.clone() 是因为 PendingRequest 里还留着一份 headers;tag 之后的 #3116(「avoid unnecessary HeaderMap clones when dispatching requests」,尚未发布)删掉了这个字段,改成直接 move。
0.13.5 的 execute_request 不加 Accept-Encoding,也不加 cookie。这两件事现在由栈里的层做。
Accept-Encoding 从 execute_request 搬进了 tower-http,也丢了一个特例0.12.24 及以前,reqwest 有自己的 async_impl/decoder.rs(746 行),Accept-Encoding 在 execute_request 里加:
let accept_encoding = self.inner.accepts.as_str();
if let Some(accept_encoding) = accept_encoding {
if !headers.contains_key(ACCEPT_ENCODING) && !headers.contains_key(RANGE) {
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static(accept_encoding));
}
}
提交 b7c3712(「Use decompression from tower-http」,#2840,关联 issue #2575)删掉了 decoder.rs 和上面这段,改成在栈的最外层包 tower-http 的 Decompression,整个提交 121 行增、812 行删。首次发布在 v0.12.25,CHANGELOG 写的是「Refactor internal decompression handling to use tower-http.」
tower-http 的 Decompression::call 只看请求里有没有 Accept-Encoding,不看 Range。于是旧代码里「带 Range 的请求不声明压缩」这条规则没了。wire 实测,带 range: bytes=0-9 的请求照样带 accept-encoding: gzip:
== range
[ 18ms server] request head:
GET /r HTTP/1.1
range: bytes=0-9
accept: */*
accept-encoding: gzip
host: 127.0.0.1:61730
同一个 tower 化的过程还有:FollowRedirect 来自 #2617(首次发布 v0.12.16),retry 来自 #2763(v0.12.23),CookieService 来自 #2787(v0.12.24,CHANGELOG:「Refactor cookie handling to an internal middleware.」),connector_layer 来自 #2496(v0.12.10)。本站用的 0.12.28 已经包含这些。
build 组装的栈
ClientBuilder::build 的结尾,从里往外一层层包:
let hyper_client = builder.build(connector_builder.build(config.connector_layers));
let hyper_service = HyperService {
hyper: hyper_client,
};
// ...
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());
#[cfg(any(
feature = "gzip",
feature = "brotli",
feature = "zstd",
feature = "deflate"
))]
let hyper = Decompression::new(hyper)
// set everything to NO, in case tower-http has it enabled but
// reqwest does not. then set to config value if cfg allows.
.no_gzip()
.no_deflate()
.no_br()
.no_zstd();
#[cfg(feature = "gzip")]
let hyper = hyper.gzip(config.accepts.gzip);
// ...
结果的类型写在 LayeredService 里,没有一个 Box<dyn Service>,全是具体类型:
type LayeredService<T> = MaybeDecompression<
FollowRedirect<
MaybeCookieService<tower::retry::Retry<crate::retry::Policy, T>>,
TowerRedirectPolicy,
>,
>;
MaybeDecompression<T> 和 MaybeCookieService<T> 在对应 feature 没开时就是 T 本身,这一层在类型里直接消失。从外到里:
| 层 | 来自 | 请求进来时做什么 | 详见 |
|---|---|---|---|
Decompression | tower-http | 没有 Accept-Encoding 就按开着的算法插一个 | 响应体 |
FollowRedirect | tower-http | 先复制一份 body 和头,留着跟 3xx 用 | 重定向 |
CookieService | reqwest | 没有 Cookie 头就从 cookie 库里取 | cookie |
tower::retry::Retry | tower | 用 clone_request 复制一份,留着重试用 | 重试 |
HyperService | reqwest | 把 hyper-util 的错误转成 reqwest::Error | 下文 |
HyperService 包着的是 hyper-util 的 legacy Client<Connector, Body>,池、HTTP/1 和 HTTP/2 都在它里面,见 hyper 卷的 legacy Client(那一卷锁的也是 hyper 1.12.0、hyper-util 0.1.21)。Connector 里可以用 connector_layer 插用户自己的层,它们只在新建连接时经过,见 连接器。Retry 的状态机见 tower 卷的 Policy 与 ResponseFuture。
表里没有超时。总超时和读超时不是 tower 层,它们是 Pending 里的两个 Sleep(下文)和响应 body 外面的包装(见 超时);connect_timeout 在连接器里。
顺序可以看出来
层的先后决定了一些可以观察的行为。layers 的第一段:客户端开 cookie 库,配一个重试策略(retry::for_host("127.0.0.1"),状态 503 算可重试)。服务器的 /set 回 302 跳到 /land 并带 set-cookie: hop=1;/flaky 第一次回 503 并带 set-cookie: retry=1,第二次回 200。
== cookie set on a 302 vs on a retried 503
[ 16ms server] /set cookie=[] accept-encoding=["gzip"]
[ 18ms server] /land cookie=["hop=1"] accept-encoding=["gzip"]
client: /set -> 200 OK http://127.0.0.1:62036/land
[ 19ms server] /flaky cookie=["hop=1"] accept-encoding=["gzip"]
[ 19ms server] /flaky cookie=["hop=1"] accept-encoding=["gzip"]
client: /flaky -> 200 OK
[ 19ms server] /after cookie=["hop=1"] accept-encoding=["gzip"]
- 302 上设的
hop=1,下一跳/land就带上了。FollowRedirect每跳一次都重新调一遍里面的服务,CookieService在它里面,所以每一跳都会存 cookie、取 cookie。 - 503 上设的
retry=1,重试那一次没带上,之后的/after也没有:这个 cookie 丢了。Retry在CookieService里面,CookieService::call只在最开始加一次头,重试的请求是clone_request从这份请求复制出来的;它的ResponseFuture也只看见Retry最后交出的那个 200,中间 503 的Set-Cookie根本没经过它。 accept-encoding: gzip在每个请求上都有。base模板只开了gzip,所以值是gzip;Decompression先把四种算法都关掉、再按 reqwest 自己的配置打开,这是 #2889 加的(「disable default compression from tower-http if not enabled in reqwest」,首次发布 v0.12.26):tower-http 的Decompression::new默认打开它自己编进来的所有算法,依赖图里别的 crate 给 tower-http 开了decompression-br,reqwest 没开brotli也会发br。CHANGELOG 的说法是「Fix sendingAccept-Encodingheader only with values configured with reqwest, regardless of underlying tower-http config.」
第二段在连接器上插两个只打印的层,看连接器什么时候被调用:
let c = reqwest::Client::builder()
.no_proxy()
.connector_layer(LogLayer("inner"))
.connector_layer(LogLayer("outer"))
.build()
.unwrap();
let base = ms();
let fut = c.get(srv.url("/lazy")).send();
println!("[{:>4}ms] send() returned (+{}ms)", ms(), ms() - base);
tokio::time::sleep(Duration::from_millis(200)).await;
println!("[{:>4}ms] first poll", ms());
let r = fut.await.unwrap();
== when does the connector run?
[ 19ms] send() returned (+0ms)
[ 222ms] first poll
[ 222ms] connector layer "outer" called
[ 222ms] connector layer "inner" called
[ 224ms server] /lazy cookie=[] accept-encoding=["gzip"]
[ 224ms] response 200 OK
- 后加的
outer先被调用:ConnectorBuilder::build按加入顺序一层层往外包,最后加的在最外面。 send()返回时连接器还没动,等到第一次 poll 才建连接。
send 时做了什么,poll 时做了什么
execute_request 里那一句 hyper.call(req) 是同步的,它会一路调到最里层:Decompression::call 插 Accept-Encoding,FollowRedirect::call 复制头和 body,CookieService::call 查 cookie 库加 Cookie,Retry::call 调 clone_request 复制请求,最后 HyperService::call:
fn call(&mut self, req: hyper::Request<crate::async_impl::body::Body>) -> Self::Future {
let clone = self.hyper.clone();
let mut inner = std::mem::replace(&mut self.hyper, clone);
Box::pin(async move { inner.call(req).await.map_err(crate::error::request) })
}
到这里只造了一个 async 块,不执行。所以 send() 返回时,头已经定了(之后 cookie 库里新加的 cookie 不会进这个请求),body 已经复制好了,但 DNS、TCP、TLS 一样都还没做。上面「first poll」之后才出现 connector layer 那两行,就是这个原因。
问: tower 的约定是先 poll_ready 返回 Ready(Ok) 才能 call。execute_request 直接 self.inner.hyper.clone() 然后 call,没问过就绪,不算违约吗?
答: 严格说是跳过了。能这么做,是因为这一串层自己都不攒「就绪」状态:Decompression、FollowRedirect、CookieService、Retry 的 poll_ready 都只是转给里层,最里面 hyper-util Client 的 poll_ready 永远返回 Poll::Ready(Ok(())),真正的等待(池里拿连接、等 HTTP/1 连接空出来)都放在 call 返回的 future 里。连接器那一侧 hyper-util 是守约的:它用 Oneshot 调连接器,先 poll_ready 再 call,都在建连接的 future 里;所以用 connector_layer 插一个 ConcurrencyLimit,排队发生在那个 future 里,不受这里影响。poll_ready 的几条规则见 tower 卷的 Service trait。
Pending 的几种状态
Pending 外面一层,里面一个两态的枚举:
enum PendingInner {
Request(Pin<Box<PendingRequest>>),
Error(Option<crate::Error>),
}
Error:send()时就知道会失败(构建错误、scheme 不对、Uri转换失败)。第一次 poll 用take()交出错误;再 poll 一次会 panic,信息是Pending error polled more than once。Request:真正的请求,装在Box里。
PendingRequest 带着 URL(出错时填进错误)、Arc<ClientRef>、栈返回的 future in_flight,还有两个计时器:
#[pin]
in_flight: ResponseFuture,
#[pin]
total_timeout: Option<Pin<Box<Sleep>>>,
#[pin]
read_timeout_fut: Option<Pin<Box<Sleep>>>,
read_timeout: Option<Duration>,
in_flight 是 ResponseFuture 枚举,Default 包 hyper 栈的 future,开了 http3 时多一个 H3。两个 Sleep 在 execute_request 里就创建好了:
let total_timeout = self
.inner
.total_timeout
.fetch(&extensions)
.copied()
.map(tokio::time::sleep)
.map(Box::pin);
tokio::time::sleep(d) 在创建时就把截止时间定成 now + d。fetch 先看请求 extensions 里有没有 RequestBuilder::timeout 设的值,没有再用客户端的。
PendingRequest::poll 每次按固定顺序看三样东西:
if let Some(delay) = self.as_mut().total_timeout().as_mut().as_pin_mut() {
if let Poll::Ready(()) = delay.poll(cx) {
return Poll::Ready(Err(
crate::error::request(crate::error::TimedOut).with_url(self.url.clone())
));
}
}
// read_timeout_fut 同样处理
let res = match self.as_mut().in_flight().get_mut() {
ResponseFuture::Default(r) => match ready!(Pin::new(r).poll(cx)) {
Err(e) => {
return Poll::Ready(Err(e.if_no_url(|| self.url.clone())));
}
Ok(res) => res.map(super::body::boxed),
},
// ...
};
read_timeout_fut 和总超时一样在 send() 里创建,PendingRequest::poll 从不重置它:等响应头这一段,读超时其实也是一个从 send() 起算的截止时间,不是「两次读之间的间隔」。
响应头到了以后还有两步:
- 如果响应带着 tower-http 的
RequestUri扩展(FollowRedirect放进去的,记录最后一跳的地址),把self.url换成它。Response::url()返回的是最终地址,上面实验里的/set -> 200 OK http://127.0.0.1:62036/land就是这么来的。 Response::new拿走total_timeout这个Sleep,把它包到响应 body 外面。总超时是同一个计时器从send()一直数到 body 读完;read_timeout传的是Duration,body 那边每读到一块就重新计时。
Pending 本身只有两个指针大:size 实验量 send() 返回的 future,正常请求和构建错误都是 16 字节:
size_of_val(send() future) = 16 bytes (request) / 16 bytes (builder error)
PendingRequest 是 0.12.20 才装进 Box 的:#2725(「reduce size of Pending request future」),diff 把 Request(PendingRequest) 改成 Request(Pin<Box<PendingRequest>>),同时加了一个测试 test_future_size,断言 size_of::<Pending>() < 128。提交说明没有正文。in_flight 里套着五层 tower future,不装箱时这一整块都内联在 Pending 里,调用方的 async 函数每 .await 一个请求,状态机就要留出这么大的空间;装箱后只剩一个指针。
.timeout(Duration::from_millis(300)),let fut = client.get(url).send(); 之后先 sleep(400ms) 再 fut.await。服务器是本机的,立即回 200。这一次服务器会收到几个新连接?动脑筋答案
== timeout(300ms), await 400ms after send()
err after 0ms: error sending request for url (http://127.0.0.1:62036/late) / is_timeout=true / reqwest::Error { kind: Request, url: "http://127.0.0.1:62036/late", source: TimedOut }
new connections: 0
[ 882ms server] /late2 cookie=[] accept-encoding=["gzip"]
awaited 250ms after send(): Ok(200)
0 个。total_timeout 的 Sleep 在 send() 里创建,截止时间是 send() 时刻加 300ms;400ms 后第一次 poll,PendingRequest::poll 先看计时器,已经到期,直接返回 TimedOut,in_flight 一次都没被 poll,连接器没被调用,服务器什么也没收到。错误在第一次 poll 的 0ms 内返回,种类是 Request,is_timeout() 为真。对照的第二个请求只等了 250ms,没超过 300ms,正常返回 200。超时从 send() 算起,不是从 .await 算起:先攒一批 send() 的 future、再一起 join 的写法,排在后面的请求实际可用的时间会变短。
小结
execute_request只做五件事:检查 scheme 和https_only、补默认头(只填空位,默认只有accept: */*,没有User-Agent)、Url转Uri、补空 body、给 http 代理加认证头。然后同步调用栈的call,返回Pending。- 栈从外到里:
Decompression→FollowRedirect→CookieService→Retry→HyperService→ hyper-util legacyClient→Connector(connector_layer后加的在外)。全是具体类型,feature 关掉的层在类型里消失。 Accept-Encoding和 cookie 是层加的,不在execute_request里;0.12.25 起的Accept-Encoding不再避开带Range的请求。- 层的顺序可以观察:302 上的 cookie 跟到下一跳;被重试的 503 上的
Set-Cookie丢失。 send()时加头、复制 body;第一次 poll 才建连接。总超时和读超时在Pending里,不是 tower 层;总超时从send()起算,同一个计时器带进响应 body。Pending是Request(Box)或Error(Option),16 字节;Error被 poll 两次会 panic。