Rust in Everything
基于 reqwest 0.13.5

执行与 Pending

主线位置:请求主线的第二步。RequestBuilder::send 把 Request 交给 Client::execute_request,后者补上默认头、把请求送进 ClientBuilder::build 时组装好的 tower 栈,返回一个 Pending;.await 这个 Pending,请求才真正开始连接。栈的最底下是 hyper-util 的 legacy Client,再往下是连接器(连接主线,见 连接器)。

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);
  1. scheme 不是 http / https,或者开了 https_only 却是 http,返回带 URL 的 Builder 错误(请求构建 里 ftp:// 那一行)。
  2. 默认头只填空位:请求已经有同名头,默认值整个不加。默认头来自 ClientBuilder::new,只有一个 accept: */*;user_agent()、default_headers() 往同一个 HeaderMap 里插。reqwest 默认不发 User-Agent,wire 打印的请求头里确实没有这一行。
  3. Url 转成 http::Uri。try_uri 就是 url.as_str().parse(),Url 能接受、Uri 不接受的(比如主机名里有 {),在这里变成 url_invalid_uri。
  4. 没有 body 就给一个空的 Body。
  5. 目标是 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。这两件事现在由栈里的层做。

📝NOTE
冷知识: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 本身,这一层在类型里直接消失。从外到里:

层来自请求进来时做什么详见
Decompressiontower-http没有 Accept-Encoding 就按开着的算法插一个响应体
FollowRedirecttower-http先复制一份 body 和头,留着跟 3xx 用重定向
CookieServicereqwest没有 Cookie 头就从 cookie 库里取cookie
tower::retry::Retrytower用 clone_request 复制一份,留着重试用重试
HyperServicereqwest把 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 sending Accept-Encoding header 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 那两行,就是这个原因。

📝NOTE
没有蠢问题

问: 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 一个请求,状态机就要留出这么大的空间;装箱后只剩一个指针。

❗IMPORTANT
动脑筋:客户端设 .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 legacy Client → 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。

💬 关联讨论

发起讨论
加载中...