GET 的流式 body 被静默丢掉
主线位置:请求主线,hyper 把请求头写上线、决定 body 怎么分帧的那一步。reqwest 把
Body交给 hyper,长度已知就是Content-Length,未知就交给 hyper 决定;两种形态和线上的样子见 请求体与 multipart。
flowchart TB
body["流式 body 长度未知"] --> ver{"HTTP 版本"}
ver -->|"HTTP/2"| h2["DATA 帧照发"]
ver -->|"HTTP/1.1"| m{"方法"}
m -->|"POST PUT 等"| ch["chunked 发出"]
m -->|"GET HEAD CONNECT"| zero["按 0 长度"]
zero --> drop["流不被读 不报错"]
hdr["手写长度或 chunked"] --> ok["照发"]
实验 crate:exp-reqwest/pitb-lab(src/bin/getbody.rs)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,客户端 .no_proxy()。服务器是 testsrv 的 /echo:明文的走 HTTP/1.1,TLS 的经 ALPN 走 HTTP/2,回显方法、版本、请求头和收到的 body 字节数。
现象
有些 API 把查询放在 GET 的 body 里,比如一些搜索服务的查询接口。查询存在文件里,顺手把文件当 body 发:
let file = tokio::fs::File::open(&path).await.unwrap();
show("GET Body::from(File)", c.get(&u).body(file)).await;
同一段 26 字节的查询,用几种写法发给同一个服务器:
query is 26 bytes
GET wrap_stream GET /echo HTTP/1.1 body: 0 bytes
GET Body::from(File) GET /echo HTTP/1.1 body: 0 bytes
POST wrap_stream POST /echo HTTP/1.1 transfer-encoding: chunked, body: 26 bytes
GET Bytes GET /echo HTTP/1.1 content-length: 26, body: 26 bytes
GET wrap_stream + content-length GET /echo HTTP/1.1 content-length: 26, body: 26 bytes
GET wrap_stream + chunked GET /echo HTTP/1.1 transfer-encoding: chunked, body: 26 bytes
GET wrap_stream over h2 GET https://localhost:52175/echo HTTP/2.0 body: 26 bytes
- 前两行:服务器收到 0 字节,没有
Content-Length,也没有Transfer-Encoding。客户端没有任何错误,请求正常返回。服务端看到的是一个没有 body 的 GET,按「没有查询条件」处理,回的结果看上去也很正常。 - 换成
POST,同一个流以 chunked 发出,26 字节一个不少。 GET配完整的Bytes,长度已知,照常发出。- 同一个流式
GET换到 HTTP/2,26 字节照发。本机用 HTTPS 测试时 ALPN 多半选了 h2,看不出问题;对端只说 HTTP/1.1 时就丢了。
原因
Body::wrap_stream 和 impl From<tokio::fs::File> for Body 都生成长度未知的流式 body(wrap_stream、From<File>),size_hint 没有确切值。reqwest 不替它做决定,原样交给 hyper。
HTTP/1.1 怎么给 body 分帧由 hyper 1.12.0 的 Client::set_length 决定。用户没写 Transfer-Encoding、也没写 Content-Length、body 长度未知时:
} else if let BodyLength::Unknown = body {
// GET, HEAD, and CONNECT almost never have bodies.
//
// So instead of sending a "chunked" body with a 0-chunk,
// assume no body here. If you *must* send a body,
// set the headers explicitly.
match head.subject.0 {
Method::GET | Method::HEAD | Method::CONNECT => Some(Encoder::length(0)),
_ => {
te.insert(HeaderValue::from_static("chunked"));
// ...
Some(Encoder::chunked())
}
}
GET 得到一个长度为 0 的编码器:不写任何分帧头,流一次也不会被 poll。没有错误,也没有日志。HTTP/2 用 DATA 帧分界,没有这条规则。
同一段代码在上面还给出了出路:用户自己写的 Transfer-Encoding 和 Content-Length 优先于 body 自己报的长度(「If the user already set specific headers, we should respect them」)。
修法
按可靠程度排:
一:body 不大就先读成 Bytes。 长度已知,任何协议、任何方法都一样,也能被重试和重定向复用(流式 body 不能 try_clone)。上表第四行。
二:长度已知,就自己写 Content-Length。 文件可以先 metadata() 拿到大小:
c.get(&u).header(header::CONTENT_LENGTH, QUERY.len()).body(stream_body())
上表第五行。这个数必须和流的实际长度一致:报小了,多出的字节被截掉;报大了,请求以 early end 失败。两种情形的实测见 请求体与 multipart。
三:长度未知,显式 chunked。
c.get(&u).header(header::TRANSFER_ENCODING, "chunked").body(stream_body())
上表第六行。注意不是所有服务器都接受带 chunked body 的 GET,这一点只能对着具体服务验证。
能改接口的话,查询放 body 的请求用 POST 是最省心的。
怎么检查
- 在服务端打印收到的 body 长度,或者用一个回显服务器(像这里的
/echo)对着同样的代码跑一遍:GET 请求没有content-length、也没有transfer-encoding,body 就一定是空的。 - 代码里搜
.get(或Method::GET后面接.body(的地方,看 body 是不是wrap_stream、File或其他流。 - 测试要用 HTTP/1.1 跑一遍:HTTPS 本地服务器经 ALPN 多半协商成 HTTP/2,会把这个问题藏起来;客户端可以
http1_only()。
小结
- HTTP/1.1 上,
GET/HEAD/CONNECT带长度未知的 body(wrap_stream、tokio::fs::File),hyper 按 0 长度发,流不被读,客户端不报错。 - 同一个请求走 HTTP/2 会照发,所以本地 HTTPS 测试可能看不出来。
- 修法:小 body 先读成
Bytes;长度已知写Content-Length;未知写Transfer-Encoding: chunked;能改接口就用POST。