Rust in Everything
基于 reqwest 0.13.5

mini-client

主线位置:三条主线各走最短的一段。请求主线:send 起一个总超时,进重定向循环,把请求交给 legacy Client;连接主线:池里没有连接时调自己写的 HttpsConnector,TCP 之后做 TLS,按 ALPN 告诉池子这是不是 HTTP/2;响应主线:非重定向的响应把 body 一次读完,再按需解析 JSON。

flowchart TB
  send["send 起总超时"] --> follow["follow 循环"]
  follow --> once["once 发一次"]
  once --> legacy["legacy Client"]
  legacy --> pool{"池里有连接"}
  pool -- 有 --> wire["写请求读响应头"]
  pool -- 没有 --> conn["HttpsConnector"]
  conn --> tcp["HttpConnector"]
  tcp --> tls["tokio-rustls"]
  tls --> h2flag["negotiated_h2"]
  h2flag --> wire
  wire --> redir{"3xx 且有 Location"}
  redir -- 是 --> follow
  redir -- 否 --> read["读完 body"]

本页的代码在 exp-reqwest/site-mini,一个库加一个测试文件。库 295 行(去掉空行和注释 243 行),只依赖这些:

[dependencies]
hyper = { version = "1", features = ["client", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"] }
http-body-util = "0.1"
bytes = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
tower-service = "0.3"
url = "2"
serde = "1"
serde_json = "1"

锁文件解析到 hyper 1.12.0、hyper-util 0.1.21、tokio-rustls 0.26.6、rustls 0.23.46、ring 0.17.14、url 2.5.8,和 reqwest 0.13.5 底下的 hyper、hyper-util、rustls 是同一版本。provider 用 ring(reqwest 0.13 默认是 aws-lc-rs),对本页的代码没有影响。测试用共享的 testsrv(明文和 HTTPS,HTTP/1.1 和 HTTP/2,/echo、/redirect/N、?head_ms= 等行为见它的 README)。平台:M2 Max、macOS 27、rustc 1.99.0,--release。

连接器:一个 Service<Uri>

legacy Client 不管连接怎么建,它只要一个 tower::Service<Uri>,返回实现了 hyper 的 Read、Write 和 hyper-util 的 Connection 的流。hyper-util 自带的 HttpConnector 满足这个要求,但只会 TCP;HTTPS 要在它外面再包一层:

impl Service<Uri> for HttpsConnector {
  type Response = MaybeTls;
  type Error = BoxError;
  type Future = Pin<Box<dyn Future<Output = Result<MaybeTls, BoxError>> + Send>>;

  fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
    self.http.poll_ready(cx).map_err(Into::into)
  }

  fn call(&mut self, uri: Uri) -> Self::Future {
    let mut http = self.http.clone();
    let tls = self.tls.clone();
    Box::pin(async move {
      let https = uri.scheme_str() == Some("https");
      let host = uri.host().ok_or("uri without host")?.trim_matches(['[', ']']).to_string();
      let tcp = http.call(uri).await?;
      if !https {
        return Ok(MaybeTls::Plain(tcp));
      }
      let name = ServerName::try_from(host)?;
      let stream = tls.connect(name, tcp.into_inner()).await?;
      Ok(MaybeTls::Tls(TokioIo::new(stream)))
    })
  }
}
  • HttpConnector 默认拒绝非 http 的 URI,构造时要 enforce_http(false)。DNS、Happy Eyeballs、TCP 选项都还是它的,见 hyper 卷的 HttpConnector。
  • Future 必须是 Unpin + Send,所以装进 Pin<Box<...>>。reqwest 的 Connector 也是这样做的。
  • MaybeTls 是 TokioIo<TcpStream> 和 TokioIo<TlsStream<TcpStream>> 的枚举,Read / Write 的四个方法逐个转发,占了库里约 30 行。reqwest 在 connect.rs 里为每种组合(TCP、Unix socket、经代理、native-tls、rustls)各写一个 Conn 包装,做的是同一件事。

比转发重要的是 Connection。连接池要知道 ALPN 选了什么:

impl Connection for MaybeTls {
  fn connected(&self) -> Connected {
    match self {
      MaybeTls::Plain(s) => s.connected(),
      MaybeTls::Tls(s) => {
        let (tcp, session) = s.inner().get_ref();
        // The pool keys HTTP/2 connections differently: tell it what ALPN picked.
        if session.alpn_protocol() == Some(b"h2") {
          tcp.connected().negotiated_h2()
        } else {
          tcp.connected()
        }
      }
    }
  }
}

legacy Client 在 connect_to 里读 connected.alpn:是 H2 就用 HTTP/2 握手,并把这次取连接改成 HTTP/2 的共享取法;否则按 HTTP/1.1。reqwest 的 rustls 分支是同一段判断,见 impl Connection for RustlsTlsConn。

📝NOTE
没有蠢问题

问: 服务端已经在 ALPN 里选了 h2,客户端不调 negotiated_h2(),hyper-util 自己看不出来吗?

答: 看不出来。它拿到的只是一个能读写字节的流,ALPN 的结果在 rustls 的会话对象里,只有连接器能告诉它。把那个 if 改成 if false && ... 再跑,对一个只接受 h2 的 testsrv(Proto::H2):

== without negotiated_h2
error: request failed: client error (SendRequest)
  caused by: client error (SendRequest)
  caused by: invalid HTTP version parsed
== with
HTTP/2.0 hello https://localhost:64565/ (conn #1 req #1 total #1)

客户端在一条已经约定为 h2 的连接上写了 HTTP/1.1 请求,服务端回的是 HTTP/2 的帧,客户端按 HTTP/1.1 解析,失败。对 Proto::Auto 的服务端倒是能「成功」,因为 hyper-util 的 auto 服务端按前几个字节识别协议、不看 ALPN(见 hyper 卷的 auto),但并发 10 个请求就开 10 条 HTTP/1.1 连接,预热之后也一样。

Client::new:TLS 配置和连接池

  pub fn new(roots: rustls::RootCertStore, timeout: Duration) -> Result<Client, Error> {
    let provider = Arc::new(rustls::crypto::ring::default_provider());
    let mut tls = rustls::ClientConfig::builder_with_provider(provider)
      .with_safe_default_protocol_versions()
      .map_err(Error::Tls)?
      .with_root_certificates(roots)
      .with_no_client_auth();
    tls.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];

    let mut http = HttpConnector::new();
    http.enforce_http(false); // we hand it https:// URIs too
    http.set_nodelay(true);
    let connector = HttpsConnector { http, tls: TlsConnector::from(Arc::new(tls)) };

    let inner = Legacy::builder(TokioExecutor::new())
      .pool_timer(TokioTimer::new())
      .timer(TokioTimer::new())
      .build(connector);
    Ok(Client { inner, timeout })
  }
  • builder_with_provider 显式给 provider,不走进程默认值,所以不管依赖图里编进几个 provider 都不会 panic(这个坑见 rustls 卷的 CryptoProvider)。reqwest 先 get_default()、没有才用自己的默认,见 TLS 配置。
  • 根证书只认调用方给的 RootCertStore。测试里只放 testsrv 的 CA;拿空集合去连,错误链的最后一环是 rustls 的原话:
test unknown_ca_is_rejected ... request failed: client error (Connect)
  caused by: client error (Connect)
  caused by: invalid peer certificate: UnknownIssuer
  • pool_timer 让池子在后台清理空闲连接,timer 给 HTTP/2 的 keep-alive 用;reqwest 在 build 里给 legacy Client 配的也是这两个,见 hyper 卷本站的出站客户端的表。空闲超时、每主机上限用 hyper-util 的默认值(90 秒、不限),和 reqwest 的默认值相同。
  • Client 派生 Clone:legacy Client 内部就是 Arc,克隆共享同一个池。

测试对同一个 HTTPS testsrv 连发三个请求,Proto::Auto(ALPN 给 h2、http/1.1)和 Proto::H1(只给 http/1.1)各一组:

test https_alpn_and_connection_reuse ... Auto HTTP/2.0 hello https://localhost:64670/a (conn #1 req #1 total #1)
Auto HTTP/2.0 hello https://localhost:64670/b (conn #1 req #2 total #2)
Auto HTTP/2.0 hello https://localhost:64670/c (conn #1 req #3 total #3)
Auto: conns=1 handshakes=1 reqs=3
H1 HTTP/1.1 hello /a (conn #1 req #1 total #1)
H1 HTTP/1.1 hello /b (conn #1 req #2 total #2)
H1 HTTP/1.1 hello /c (conn #1 req #3 total #3)
H1: conns=1 handshakes=1 reqs=3
ok

两组都是一条连接、一次握手。连接复用这一项完全是 hyper-util 的连接池给的,库里没有一行代码管它。

一个超时管全程

  /// The clock starts here and stops when the last body byte is in.
  pub async fn send(&self, method: Method, url: &str, headers: HeaderMap, body: Bytes) -> Result<Response, Error> {
    let url = Url::parse(url).map_err(|e| Error::Url(e.to_string()))?;
    match tokio::time::timeout(self.timeout, self.follow(method, url, headers, body)).await {
      Ok(result) => result,
      Err(_) => Err(Error::Timeout(self.timeout)),
    }
  }

follow 里包括所有重定向和最后一次读 body,所以一个 tokio::time::timeout 就覆盖了连接、等响应头、读响应体三段。代价是 Response 只能拿到读完的 Bytes:body 一旦交给调用方流式读,计时就得跟着 body 走,reqwest 为此把 Sleep 塞进 TotalTimeoutBody,见超时。

测试让 testsrv 分别在响应头之前睡 1000 ms、在三个 body 块之间各睡 400 ms,总超时 300 ms:

test total_timeout_covers_head_and_body ... /?head_ms=1000: timed out after 300ms after 303 ms
/?chunks=3&body_ms=400: timed out after 300ms after 302 ms
ok

连跑 3 次(uptime 的 1 分钟负载约 5),两种情况都在 302 到 303 ms 返回。第二种情况里响应头早就到了,超时发生在读 body 的中途,这正是只设 connect_timeout 时漏掉的那一段。

重定向

  async fn follow(&self, mut method: Method, mut url: Url, mut headers: HeaderMap, mut body: Bytes) -> Result<Response, Error> {
    for _ in 0..=MAX_REDIRECTS {
      let resp = self.once(&method, &url, &headers, body.clone()).await?;
      // Same method rules as tower-http's FollowRedirect; other statuses end here.
      let to_get = match resp.status() {
        StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => method == Method::POST,
        StatusCode::SEE_OTHER => true,
        StatusCode::TEMPORARY_REDIRECT | StatusCode::PERMANENT_REDIRECT => false,
        _ => return read(resp, url).await,
      };
      let location = resp.headers().get(header::LOCATION).and_then(|l| l.to_str().ok());
      let Some(next) = location.and_then(|l| url.join(l).ok()) else {
        return read(resp, url).await;
      };
      // Read the (small) redirect body so an HTTP/1 connection goes back to the pool.
      resp.into_body().collect().await.map_err(Error::Body)?;

      if to_get {
        if method != Method::HEAD {
          method = Method::GET;
        }
        body = Bytes::new();
        headers.remove(header::CONTENT_TYPE);
        headers.remove(header::CONTENT_LENGTH);
      }
      if origin(&next) != origin(&url) {
        for name in &SENSITIVE {
          headers.remove(name);
        }
      }
      url = next;
    }
    Err(Error::TooManyRedirects(url))
  }
  • 最多 5 次。 循环最多发 6 个请求,第 6 个还是重定向就报错。
  • 方法规则抄 tower-http。 301、302 只把 POST 改成 GET,303 除 HEAD 外都改成 GET,307、308 原样重发。改成 GET 时丢掉 body 和两个描述 body 的头。请求体是 Bytes,每次 clone 只加引用计数,所以 307 总能重发;reqwest 的流式 body 不能克隆,遇到 307 只能把 3xx 交回给调用方,见重定向。
  • 跨源去头。 origin 是 (scheme, host_str, port_or_known_default),三者都相同才算同源;SENSITIVE 是 Authorization、Cookie、Proxy-Authorization、WWW-Authenticate,即 reqwest remove_sensitive_headers 的列表去掉已经废弃的 Cookie2。比较的是上一跳和下一跳,和 reqwest 一样。
  • 读完重定向响应的 body。 HTTP/1.1 下 body 没读完就丢,连接不能回池。读完以后整条重定向链用一条连接,测试里 /redirect/5 的最后一跳答的是 conn #1。

测试输出:

test credentials_stay_on_their_origin ... same origin  http://127.0.0.1:64662/echo ["authorization: Bearer secret", "cookie: sid=1"]
other port   http://127.0.0.1:64663/echo []
localhost    http://localhost:64662/echo []
ok
test five_redirects_on_one_connection_then_stop ... 200 OK http://127.0.0.1:64668/redirect/0 -> redirect chain done (conn #1)
more than 5 redirects, next http://127.0.0.1:64668/redirect/0
ok
test see_other_becomes_get_temporary_redirect_replays ... 303: GET /echo HTTP/1.1 | - | body: 0 bytes
307: POST /echo HTTP/1.1 | content-type: application/json | body: 7 bytes
ok

localhost 那一行和原请求同一个端口、同一个进程,只是主机字符串从 127.0.0.1 换成了 localhost,也算跨源。为了确认这个测试真的能抓住问题,把 if origin(&next) != origin(&url) 临时改成 if false && ...,它就失败在 left: 2, right: 0(两个凭据头被带到了另一个端口)。

JSON 和错误

  pub fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
    serde_json::from_slice(&self.body).map_err(|source| Error::Json {
      source,
      body: String::from_utf8_lossy(&self.body[..self.body.len().min(200)]).into_owned(),
    })
  }

因为 body 已经整个在 Response 里,解码失败时顺手把前 200 字节放进错误。对 testsrv 的纯文本响应调 json():

test json_round_trip_and_readable_decode_errors ... Echo { ok: true, n: 3 }
json: expected value at line 1 column 1; body starts "hello /not-json (conn #1 req #1 total #1)"
ok

reqwest 的 json() 读完 body 就交给 serde_json,失败时 body 已经丢了,错误里只有 serde_json 的位置信息(0.13.4 起加上了 URL),想看原文得先 text() 再自己解析,见响应体。反过来,Error 只有七个变体,没有 is_timeout()、is_connect() 这类分类,连接阶段和发送阶段的失败都装在 Send 里,调用方要自己沿 source() 往下找;reqwest 的分法见错误。

post_json 只做两件事:serde_json::to_vec,插入 Content-Type: application/json。

比 reqwest 少了什么

7 个测试都通过,但这只是 reqwest 的一个子集。逐项列出缺的东西:

缺的reqwest 怎么做讲它的页面
请求构建与延迟报错RequestBuilder 把错误存到 send 时才报,header、query、form、basic_auth 一应俱全请求构建
默认头补 accept: */*,HTTP 目标经代理时补代理认证执行与 Pending
流式请求体、multipartBody 有完整和流式两种形态,multipart 生成边界和长度请求体与 multipart
可替换的连接器层connector_layer 在连接器外套 tower 层连接器
DNS 定制Resolve trait、hickory、resolve() 覆盖DNS
TLS 选择rustls 或 native-tls,平台校验器、客户端证书、版本范围、tls_infoTLS 配置
代理显式代理、系统代理、NO_PROXY、CONNECT 隧道、SOCKS代理
池与协议配置空闲超时、每主机上限、http2_prior_knowledge、HTTP/3连接池与 HTTP 版本
流式读响应、解压、字符集chunk、bytes_stream,tower-http 解压层,按 charset 解码响应体
分段超时connect_timeout、read_timeout,总超时跟着 body 走超时
可配置的重定向redirect::Policy(默认 10 次、自定义闭包)、Referer、https_only重定向
重试默认重试 HTTP/2 的 REFUSED_STREAM 和 GOAWAY,可配预算重试
错误分类Kind 加 is_timeout / is_connect / is_status,带 URL错误
cookieJar、CookieStore,跨重定向cookie
阻塞接口后台线程跑运行时,经通道转发阻塞客户端
WASM浏览器里直接调 fetchWASM

reqwest 的 src/ 在 tag 上一共 20323 行,这里是 295 行。差出来的部分,大多是上表里的选项和三套实现(异步、阻塞、WASM)共用的类型;真正「把请求送出去、把响应拿回来」的那一段,hyper-util 已经替两边都做了。

❗IMPORTANT
动脑筋:一个新建的 mini-client 并发 10 个请求打到 HTTPS 服务器,服务端会看到几条 TCP 连接、几次 TLS 握手?请求都落在哪条上?

服务端是 Proto::Auto 的 testsrv(ALPN 给 h2 和 http/1.1)。10 个任务各拿一份 c.clone(),同时 get,收集响应里的 x-conn。再做一组:先发一个请求预热,再并发 10 个。代码在 examples/race.rs。

动脑筋答案

连跑 3 次:

warm=false: versions=["HTTP/2.0"] answered on conn ["1"]; conns=10 tls_handshakes=10 reqs=10
warm=true: versions=["HTTP/2.0"] answered on conn ["1"]; conns=1 tls_handshakes=1 reqs=11
warm=false: versions=["HTTP/2.0"] answered on conn ["1"]; conns=10 tls_handshakes=10 reqs=10
warm=true: versions=["HTTP/2.0"] answered on conn ["1"]; conns=1 tls_handshakes=1 reqs=11
warm=false: versions=["HTTP/2.0"] answered on conn ["7"]; conns=10 tls_handshakes=10 reqs=10
warm=true: versions=["HTTP/2.0"] answered on conn ["1"]; conns=1 tls_handshakes=1 reqs=11
  • 冷启动:10 条 TCP、10 次 TLS 握手,但 10 个请求全落在同一条连接上(第三次是 #7)。10 个请求同时到池里,池里什么都没有,也还不知道这个主机会说 HTTP/2,于是每个请求各自起一次连接。第一个握手完成、ALPN 报出 h2 的连接通过 alpn_h2 把这次取连接改成共享的 HTTP/2 连接,其余请求都排到它上面;后到的 9 条连接在 connect_to 里拿不到锁,以「ALPN upgraded to HTTP/2」取消。哪条先握完手,取决于调度,所以第三次赢的是 #7。
  • 预热以后:池里已经有一条 HTTP/2 连接,10 个请求直接多路复用,只有 1 条连接、1 次握手。

这和 reqwest 一模一样,因为这段逻辑在 hyper-util 的 legacy Client 里,不在 reqwest:连接池与 HTTP 版本用 reqwest 并发 100 个请求测到的是 100 次握手、只用 1 条。机制见 hyper 卷连接池的「和新建连接赛跑」一节。

小结

  • hyper-util 的 legacy Client 已经包办了连接池、HTTP/1.1 和 HTTP/2、Host 头和 URI 形式;自己要写的是一个 Service<Uri> 连接器,HTTPS 时在 TCP 上套 TLS,并通过 Connection::connected 用 negotiated_h2 报告 ALPN 的结果。漏掉这一步,h2 连接上会写出 HTTP/1.1 请求。
  • 一个 tokio::time::timeout 包住重定向循环和读 body,就是覆盖全程的总超时;代价是只能返回读完的 body。卡在响应头和卡在响应体都在 302 到 303 ms 结束。
  • 重定向约 35 行:最多 5 次,方法规则和 tower-http 相同,跨 scheme、主机、端口时去掉 Authorization 等四个头,读完中间响应的 body 让连接回池。
  • 295 行对 20323 行:reqwest 多出来的是代理、DNS、TLS 选择、分段超时、解压、重试、cookie、错误分类、阻塞和 WASM 接口,每一项都有一页。

💬 关联讨论

发起讨论
加载中...