Rust in Everything
基于 reqwest 0.13.5

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。

💬 关联讨论

发起讨论
加载中...