响应体
主线位置:响应主线的全部。
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 → Bytes | Decode | 是 |
text() / text_with_charset() | self → String | Decode | 是 |
json::<T>() | self → T | Decode | 是 |
chunk() | &mut self → Option<Bytes> | Decode | 否 |
bytes_stream() | self → impl Stream | Decode | 否 |
前三个都走 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。
#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 条,说明在本机这个测试里,每次取连接时上一条连接都已经回到池里了。
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 卷 响应解压,它和压缩共用的编码内核见 共享内核,服务端方向的请求解压见 请求解压。