Rust in Everything
基于 reqwest 0.13.5

响应体

主线位置:响应主线的全部。PendingRequest::poll 拿到响应头,用 Response::new 包好交给调用方(这一步见 执行与 Pending);之后每一次 bytes()、text()、chunk() 都是在拉 body:穿过超时包装和 tower-http 的解压,到 hyper 的 Incoming;body 读到结尾,HTTP/1.1 连接才回到 hyper-util 的池里。

flowchart TB
  pend["PendingRequest 得到头"] --> new["Response::new"]
  new --> wrap["body::response 套超时"]
  wrap --> api{"读法"}
  api -- 整体 --> whole["bytes text json"]
  api -- 分块 --> part["chunk bytes_stream"]
  whole --> collect["do_bytes 收集"]
  collect --> done["body 读完"]
  part --> done
  done --> back["HTTP/1.1 连接回池"]

实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5(模板的 feature:默认加 gzip、json、stream 等),release 构建。服务端是本机的 hyper-util 服务器:连接计数用卷里共用的 testsrv,gzip、字符集、JSON 的固定响应用 exp-reqwest/resp-lab 里的一个小服务器,它把收到的 Accept-Encoding 原样放进响应头 x-seen-ae。客户端一律 .no_proxy()。每个实验连跑 3 次,除端口号外输出相同。

Response 里有什么

Response 只有两个字段:

pub struct Response {
    pub(super) res: hyper::Response<ResponseBody>,
    // Boxed to save space (11 words to 1 word), and it's not accessed
    // frequently internally.
    url: Box<Url>,
}

res 是 hyper 的响应,body 类型 ResponseBody 是一个装箱的 BoxBody<Bytes, Box<dyn Error + Send + Sync>>:下面是什么(解压体、超时包装、hyper 的 Incoming)都被擦掉了。url 是最终地址,经过重定向时是最后一跳。

Response::new 只做一件事:把 body 交给 body::response,按客户端有没有设总超时、读超时,在外面套 TotalTimeoutBody、ReadTimeoutBody,两个都没设就只装箱。两个包装怎么计时见 超时。

头上的几个方法都是同步的:status()、version()、headers()、url()、remote_addr()(从 hyper-util 放进扩展的 HttpInfo 里取)、extensions()。content_length() 不读 Content-Length 头,读的是 body 的 size_hint().exact();解压之后 body 长度未知,它就是 None,下面的实验会看到。

五种读法

方法签名失败时的 Kind错误带 URL
bytes()self → BytesDecode是
text() / text_with_charset()self → StringDecode是
json::<T>()self → TDecode是
chunk()&mut self → Option<Bytes>Decode否
bytes_stream()self → impl StreamDecode否

前三个都走 do_bytes:

    async fn do_bytes(self) -> crate::Result<(Bytes, Box<Url>)> {
        use http_body_util::BodyExt;

        match BodyExt::collect(self.res.into_body()).await {
            Ok(buf) => Ok((buf.to_bytes(), self.url)),
            Err(err) => Err(crate::error::decode(err).with_url(*self.url)),
        }
    }

collect 一直 poll 到 body 结束,把所有数据帧攒成一个 Bytes;没有大小上限,服务器回多大就攒多大。错误一律包成 Kind::Decode,不管原因是连接断了、解压失败还是超时(错误里有完整的链)。

json 在 do_bytes 后面接一个 serde_json::from_slice,同样包成 Decode 并带上 URL。它不看 Content-Type:服务器回一页 HTML,错误是 serde_json 的 expected value at line 1 column 1,原文已经被 self 一起消费掉了,怎么留住见 错误。

chunk 是唯一借用 &mut self 的读法,一次返回一个数据帧,跳过 trailers 之类的非数据帧:

    pub async fn chunk(&mut self) -> crate::Result<Option<Bytes>> {
        use http_body_util::BodyExt;

        // loop to ignore unrecognized frames
        loop {
            if let Some(res) = self.res.body_mut().frame().await {
                let frame = res.map_err(crate::error::decode)?;
                if let Ok(buf) = frame.into_data() {
                    return Ok(Some(buf));
                }
                // else continue
            } else {
                return Ok(None);
            }
        }
    }

一块有多大由 hyper 决定,和服务器写了几次无关。下面连接回池的实验里,服务器一次写 16384 字节,HTTP/1.1 下第一次 chunk() 拿到 8091 字节,HTTP/2 下拿到 16384 字节。bytes_stream(要 stream feature)是同一件事的 Stream 版本,用 http-body-util 的 BodyDataStream 包一下。chunk 和 bytes_stream 的错误都没有调 with_url,所以错误信息里没有 for url (...),超时的实验输出里能看到。

Response 还实现了 From<Response> for Body:把一个响应的 body 原样当作另一个请求的 body 转发出去,不经过内存攒整块。

字符集

text 在默认开着的 charset feature 下就是 text_with_charset("utf-8");关掉这个 feature,只剩 String::from_utf8_lossy。text_with_charset:

    pub async fn text_with_charset(self, default_encoding: &str) -> crate::Result<String> {
        let content_type = self
            .headers()
            .get(crate::header::CONTENT_TYPE)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| value.parse::<Mime>().ok());
        let encoding_name = content_type
            .as_ref()
            .and_then(|mime| mime.get_param("charset").map(|charset| charset.as_str()))
            .unwrap_or(default_encoding);
        let encoding = Encoding::for_label(encoding_name.as_bytes()).unwrap_or(UTF_8);

        let full = self.bytes().await?;

        let (text, _, _) = encoding.decode(&full);
        Ok(text.into_owned())
    }

三步:从 Content-Type 的 charset 参数取名字,没有参数才用调用方给的默认值;encoding_rs 按 WHATWG 的标签表查,查不到就是 UTF-8;Encoding::decode 先嗅 BOM,有 BOM 就按 BOM 解码并去掉它,坏字节换成 U+FFFD,不报错。

实验服务器的 body 永远是「中文测试」,按不同方式编码,Content-Type 也不同:

text/plain; charset=gbk                text()="中文测试"  text_with_charset("gbk")="中文测试"
text/plain                             text()="���IJ���"  text_with_charset("gbk")="中文测试"
text/plain; charset=no-such-charset    text()="���IJ���"  text_with_charset("gbk")="���IJ���"
text/plain                             text()="中文测试"  text_with_charset("gbk")="中文测试"
text/plain; charset=gbk                text()="中文测试"  text_with_charset("gbk")="中文测试"

五行依次是:GBK 字节并声明 charset=gbk;GBK 字节不声明;GBK 字节声明一个不存在的字符集;UTF-8 字节带 BOM;UTF-16LE 字节带 BOM 却声明 charset=gbk。

  • 第三行:头里有 charset 参数,unwrap_or(default_encoding) 不会生效;标签查不到时落到的是 UTF_8,不是调用方给的 "gbk"。默认值只在头里没有 charset 参数时才用。
  • 第四、五行:BOM 比头里的声明优先,UTF-16 的 body 声明成 GBK 也解对了。去掉 BOM 是 0.11.19 的改动(#1898)。
  • 解码永远不失败。text() 的错误只可能来自读 body 本身。

error_for_status

状态码不影响 send() 的结果:服务器回 404,send() 照样是 Ok(Response)。要把 4xx、5xx 当错误,得自己调 error_for_status:

    pub fn error_for_status(self) -> crate::Result<Self> {
        let status = self.status();
        let reason = self.extensions().get::<hyper::ext::ReasonPhrase>().cloned();
        if status.is_client_error() || status.is_server_error() {
            Err(crate::error::status_code(*self.url, status, reason))
        } else {
            Ok(self)
        }
    }

它是同步的,不碰 body。拿 self 的版本在出错时把整个 Response 丢掉,body 也就没了;想先看看服务器在错误页里说了什么,用 error_for_status_ref,它借用 &self,Response 还在手里,可以再 text()。3xx 不算错误:默认的重定向策略已经跟随过了,到这里的 3xx 是没有被跟随的那种。

实验(完整程序在 错误):

[404] send() returned Ok(404 Not Found)
[404] is_[status]
  {}   : HTTP status client error (404 Not Found) for url (http://127.0.0.1:62620/?status=404)
...
[418 custom reason] is_[status]
  {}   : HTTP status client error (418 Short And Stout) for url (http://127.0.0.1:62621/teapot)

第二个服务器把 418 的原因短语改成了 Short And Stout,错误信息里用的是服务器写的那个,而不是标准的 I'm a teapot。

📝NOTE
冷知识:错误信息里的原因短语是 0.12.19 才跟着服务器走的

#2697「refactor: report custom reason phrase in error message」(35a6601,首次发布在 v0.12.19,提交说明只有「Closes #2565」)。之前 error_for_status 只把 StatusCode 传给 status_code,Display 打印的是 StatusCode 自带的标准短语。hyper 1 只在服务器的短语和标准短语不同时,才把原文放进响应扩展 hyper::ext::ReasonPhrase;这次的 diff 就是上面代码里多出来的那一行 let reason = ...get::<hyper::ext::ReasonPhrase>(),Kind::Status 多了一个 Option<ReasonPhrase> 字段。原文来自网络,Display 用 Escape 打印它;同一个提交把 Escape 从 connect.rs 的 verbose 日志代码里移到 util.rs 共用。

解压

gzip、brotli、zstd、deflate 四个 feature 都不是 reqwest 自己实现的,Cargo.toml 里各是一行:

gzip = ["tower-http/decompression-gzip"]
brotli = ["tower-http/decompression-br"]
zstd = ["tower-http/decompression-zstd"]
deflate = ["tower-http/decompression-deflate"]

开了任意一个,build 就在栈的最外面包一层(这一段) tower-http 0.6.11 的 Decompression:先把四种算法全关掉,再按 ClientBuilder::gzip() 等方法的值逐个打开(默认开着的 feature 都是 true):

        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);

先全关的原因写在注释里:tower-http 的 feature 是整个依赖图合并的,别的 crate 打开了 decompression-br,tower-http 默认就会接受 br,而用户没给 reqwest 开 brotli。

请求方向,Decompression::call 只在请求没有 Accept-Encoding 时插一个(插入时机见 执行与 Pending)。响应方向,它的 ResponseFuture 看 Content-Encoding:

        let res =
            if let header::Entry::Occupied(entry) = parts.headers.entry(header::CONTENT_ENCODING) {
                let body = match entry.get().as_bytes() {
                    #[cfg(feature = "decompression-gzip")]
                    b"gzip" if self.accept.gzip() => DecompressionBody::new(BodyInner::gzip(
                        WrapBody::new(body, CompressionLevel::default()),
                    )),
                    // ...
                    _ => {
                        return Poll::Ready(Ok(Response::from_parts(
                            parts,
                            DecompressionBody::new(BodyInner::identity(body)),
                        )))
                    }
                };

                entry.remove();
                parts.headers.remove(header::CONTENT_LENGTH);

                Response::from_parts(parts, body)

两个细节决定了行为:解不解压看的是客户端配置 self.accept,不是这个请求实际发出去的 Accept-Encoding;匹配是对头的原始字节做精确比较。服务器(tower-http 的服务端压缩见 axum 卷的 压缩,那边锁的是 tower-http 0.7.1)一律回 gzip 字节,只改 Content-Encoding 头:

[1] default client, gzip response
    sent accept-encoding: gzip
    content-encoding: None, content-length header: None, content_length(): None
    body 53 B: "hello gzip world, hello "
[2] .gzip(false), server sends gzip anyway
    sent accept-encoding: -
    content-encoding: Some("gzip"), content-length header: Some("54"), content_length(): Some(54)
    body 54 B: [1f, 8b, 08, 00] ...
[3] default client, own Accept-Encoding: identity, server sends gzip anyway
    sent accept-encoding: identity
    content-encoding: None, content-length header: None, content_length(): None
    body 53 B: "hello gzip world, hello "
[4] Content-Encoding: GZIP
    sent accept-encoding: gzip
    content-encoding: Some("GZIP"), content-length header: Some("54"), content_length(): Some(54)
    body 54 B: [1f, 8b, 08, 00] ...
[5] Content-Encoding: x-gzip
    sent accept-encoding: gzip
    content-encoding: Some("x-gzip"), content-length header: Some("54"), content_length(): Some(54)
    body 54 B: [1f, 8b, 08, 00] ...
[6] Content-Encoding: br (feature off)
    sent accept-encoding: gzip
    content-encoding: Some("br"), content-length header: Some("54"), content_length(): Some(54)
    body 54 B: [1f, 8b, 08, 00] ...
[7] Content-Encoding: gzip, body is not gzip
    sent accept-encoding: gzip
    content-encoding: None
    error is_[decode]: error decoding response body for url (http://127.0.0.1:62205/badgz)
      <- Invalid gzip header  [std::io::Error]
[8] Content-Encoding: gzip, empty body
    sent accept-encoding: gzip
    content-encoding: None, content-length header: None, content_length(): None
    body 0 B: ""
[9] HEAD, Content-Encoding: gzip
    sent accept-encoding: gzip
    content-encoding: None, content-length header: None, content_length(): None
    body 0 B: ""
  • [1] 默认:请求带 accept-encoding: gzip,响应被解压,Content-Encoding 和 Content-Length 两个头都被删掉,content_length() 变成 None。读到的是解压后的 53 字节。
  • [2] .gzip(false):不发 Accept-Encoding,服务器硬塞 gzip 时原样交出,头也留着,1f 8b 是 gzip 魔数。要自己保存压缩文件的程序要这样关掉。
  • [3] 自己写了 Accept-Encoding: identity:头没被覆盖,但服务器不听话回了 gzip,客户端照样解压了。解不解压只看配置,所以自己写这个头只能影响服务器,影响不了客户端。
  • [4][5] GZIP 和 x-gzip:RFC 9110 说内容编码不区分大小写,并且应该把 x-gzip 当作 gzip;tower-http 按字节比较,两者都原样交出压缩字节,text() 会得到乱码。
  • [6] 没开的算法原样交出,和 [2] 一样。
  • [7] body 不是合法 gzip:send() 成功(头已经删了),错误在读 body 时出现,链的底层是 std::io::Error。
  • [8][9] 空 body 带 Content-Encoding: gzip、HEAD 请求:都返回空 body,不报错。

Content-Encoding: gzip, br 这样的多重编码也会落到 _ 分支原样交出。

连接什么时候回池

HTTP/1.1 连接一次只跑一个请求,响应 body 读到结尾、连接任务回到空闲状态以后,hyper-util 的 Pooled 才被 drop,连接才进池;HTTP/2 连接本来就由多个请求共享,拿到响应头时 hyper-util 就把手里这份引用还回去,不等 body。这段代码和 hyper 层面的四种情况(读完、drop 小响应、drop 大响应、拿着不读)在 hyper 卷的 连接池「什么时候回到池里」一节已经逐行讲过,这里只看经过 reqwest 的整套包装后结果有没有变,并补上 HTTP/2。

每种情况起一个新的 testsrv,同一个 Client 先发第一个请求,按五种方式处理它的响应,50ms 后发第二个请求,看第二个请求落在哪条连接上(x-conn 头)和服务器一共接受了几条连接:

      let mut first = client.get(srv.url(path)).send().await.unwrap();
      let mut held = None;
      match case {
        "read to end" => {
          first.bytes().await.unwrap();
        }
        "hold 1 MiB" => held = Some(first),
        "1 chunk then drop" => {
          let c = first.chunk().await.unwrap().unwrap();
          drop(first);
          println!("  (first chunk was {} B)", c.len());
        }
        _ => drop(first),
      }
      tokio::time::sleep(Duration::from_millis(50)).await;
      let second = client.get(srv.url("/second")).send().await.unwrap();

「read to end」「drop 1 MiB」「hold 1 MiB」的第一个响应是 1 MiB 的一整块,「drop 5 B」是 5 字节,「1 chunk then drop」是 64 块、每块 16384 字节的流。HTTP/1.1 的结果:

== HTTP/1.1
  read to end        second on conn #1, server accepted 1
  drop 5 B           second on conn #1, server accepted 1
  drop 1 MiB         second on conn #2, server accepted 2
  hold 1 MiB         second on conn #2, server accepted 2
  (first chunk was 8091 B)
  1 chunk then drop  second on conn #2, server accepted 2

和 hyper 卷直接用 hyper-util 的结果一样,reqwest 的包装没有改变什么:

  • 读完:复用 #1。
  • drop 5 字节:也复用了。这 5 字节读响应头时已经一起进了 hyper 的读缓冲,body 被 drop 时 hyper 试着排空,一次就排空,连接照常保持。
  • drop 1 MiB、读一块再 drop:排不空,hyper 关掉读端,连接作废,第二个请求新建 #2。只读了一部分和完全没读是一样的。
  • 拿着 1 MiB 不读也不 drop:连接没关,但被这个响应占着,不在池里,第二个请求也只能新建。

所以在 HTTP/1.1 上,「把 body 读完」是复用连接的前提;不需要 body 时,小响应 drop 掉问题不大,大响应最好读完(或者干脆接受这条连接被关掉)。

另一组:同一个 Client 连发 100 个请求,每个都 bytes() 读完后立刻发下一个,中间不 sleep:

== HTTP/1.1: 100 back-to-back requests, server accepted 1
== HTTP/2 (h2c): 100 back-to-back requests, server accepted 1

HTTP/1.1 下读完以后,连接要等一个后台小任务(hyper 卷里的 on_idle)跑完才回池。如果下一个请求取连接时它还不在池里,hyper-util 会同时开始新建连接,服务器就会多接受一条。这里 100 次只接受了 1 条,说明在本机这个测试里,每次取连接时上一条连接都已经回到池里了。

❗IMPORTANT
动脑筋:同样的五种处理方式,换成 HTTP/2(testsrv 的 h2c,客户端 .http2_prior_knowledge()),五种情况下服务器各接受了几条连接?平台:M2 Max、macOS 27,reqwest 0.13.5,release 构建。

动脑筋答案

== HTTP/2 (h2c)
  read to end        second on conn #1, server accepted 1
  drop 5 B           second on conn #1, server accepted 1
  drop 1 MiB         second on conn #1, server accepted 1
  hold 1 MiB         second on conn #1, server accepted 1
  (first chunk was 16384 B)
  1 chunk then drop  second on conn #1, server accepted 1

全是 1 条。HTTP/2 的一个响应只占连接上的一个流(stream),连接本来就是共享的,hyper-util 拿到响应头就把手里的引用还回去,不等 body。drop 一个没读完的响应只放弃那一个流,拿着不读也只占住那一个流和它的流量控制窗口,第二个请求在同一条连接上开新流。HTTP/2 的连接共享见 连接池与 HTTP 版本。

小结

  • Response 是 hyper 的响应加一个装箱的最终 URL;body 在 Response::new 里按超时配置套上 TotalTimeoutBody / ReadTimeoutBody,content_length() 是 body 的大小提示,不是头。
  • bytes、text、json 都经 do_bytes 把 body 攒成一块,错误是带 URL 的 Decode;chunk、bytes_stream 按 hyper 交出的帧分块,错误不带 URL。json 不看 Content-Type。
  • text 的字符集:头里的 charset 参数优先,没有参数才用默认值,标签不认识就是 UTF-8;BOM 比头优先;解码不会失败。
  • send() 对 4xx、5xx 返回 Ok;error_for_status 把它们变成 Kind::Status 并丢掉 body,error_for_status_ref 留着 Response;错误信息里是服务器写的原因短语(0.12.19 起)。
  • 解压是 tower-http 的 Decompression:只在请求没有 Accept-Encoding 时补一个;解不解压只看客户端配置,Content-Encoding 按字节精确匹配,GZIP、x-gzip、没开的算法都原样交出;解压后删掉 Content-Encoding 和 Content-Length。
  • HTTP/1.1 上 body 读完连接才回池,drop 一个排不空的 body 会关掉连接,拿着不读会占住连接;HTTP/2 上这些都只影响一个流。

Decompression 的内部见 tower-http 卷 响应解压,它和压缩共用的编码内核见 共享内核,服务端方向的请求解压见 请求解压。

💬 关联讨论

发起讨论
加载中...