there are extra bytes after body has been decompressed
主线位置:作用点主线的第三个时刻。解码器报告压缩流结束以后,
WrapBody继续 poll 内层 body,又收到一个非空数据帧,就报这个错。三个版本里这段结尾逻辑各不相同,在 响应解压 里用内存里的帧序列跑过;这一页换成真实的 TCP 连接,看同样的字节在网络上什么时候报错、什么时候静默截断。
flowchart TB
up["上游:两段 gzip 首尾相接"] --> how{"第二段和第一段在同一帧吗"}
how -->|"同一个 chunk"| same["解码器读完第一段就结束,缓冲里剩下的没人看"]
same --> trunc["静默:只得到 hello"]
how -->|"单独一个 chunk"| sep["解码器结束后又来一个非空帧"]
sep --> err["报错:there are extra bytes ..."]
复现
一个手写的 HTTP/1.1 服务器,回 Content-Encoding: gzip、分块传输,body 是两段 gzip(hello 和 world)首尾相接。/one 把两段放进一个 chunk,/split 分两个 chunk、中间停 50 ms。客户端两个:hyper-util 的 legacy Client 外面套 tower-http 0.7.1 的 DecompressionLayer,以及打开了 gzip 的 reqwest 0.12。
tower-http = { version = "=0.7.1", features = ["decompression-gzip"] }
tower = { version = "=0.5.3", features = ["util"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
http = "1"
http-body-util = "0.1"
bytes = "1"
tokio = { version = "=1.54.1", features = ["macros", "rt", "net", "io-util", "time"] }
reqwest = { version = "0.12", default-features = false, features = ["gzip"] }
flate2 = "1"
use bytes::Bytes;
use http_body_util::{BodyExt, Empty};
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
use std::{error::Error, io::Write, time::Duration};
use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener};
use tower::{ServiceBuilder, ServiceExt};
use tower_http::decompression::DecompressionLayer;
fn gzip(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
fn chunk(data: &[u8]) -> Vec<u8> {
let mut v = format!("{:x}\r\n", data.len()).into_bytes();
v.extend_from_slice(data);
v.extend_from_slice(b"\r\n");
v
}
// 每个请求都回两段拼接的 gzip;/split 时两段各占一个 chunk,中间停一下
async fn serve(listener: TcpListener) {
loop {
let (mut sock, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
let n = sock.read(&mut buf).await.unwrap();
let split = String::from_utf8_lossy(&buf[..n]).contains("GET /split");
let (a, b) = (gzip(b"hello "), gzip(b"world"));
sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-encoding: gzip\r\ntransfer-encoding: chunked\r\nconnection: close\r\n\r\n").await.unwrap();
if split {
sock.write_all(&chunk(&a)).await.unwrap();
sock.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
sock.write_all(&chunk(&b)).await.unwrap();
} else {
sock.write_all(&chunk(&[a, b].concat())).await.unwrap();
}
sock.write_all(b"0\r\n\r\n").await.unwrap();
});
}
}
// 把错误链拼成一行
fn chain(e: &dyn Error) -> String {
let mut s = e.to_string();
let mut cur = e.source();
while let Some(c) = cur {
s.push_str(&format!(" <- {c}"));
cur = c.source();
}
s
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(serve(listener));
let hyper = Client::builder(TokioExecutor::new()).build_http::<Empty<Bytes>>();
let reqwest = reqwest::Client::builder().no_proxy().build().unwrap();
for path in ["/one", "/split"] {
let url = format!("http://{addr}{path}");
let svc = ServiceBuilder::new().layer(DecompressionLayer::new()).service(hyper.clone());
let res = svc.oneshot(http::Request::get(&url).body(Empty::new()).unwrap()).await.unwrap();
match res.into_body().collect().await {
Ok(b) => println!("tower-http 0.7.1 {path:<6} -> {:?}", b.to_bytes()),
Err(e) => println!("tower-http 0.7.1 {path:<6} -> error: {}", chain(&*e)),
}
let res = reqwest.get(&url).send().await.unwrap();
match res.text().await {
Ok(t) => println!("reqwest 0.12 {path:<6} -> {t:?}"),
Err(e) => println!("reqwest 0.12 {path:<6} -> error: {}", chain(&e)),
}
}
}
M2 Max、rustc 1.99.0,flate2 1.1.10,跑 3 次输出相同:
tower-http 0.7.1 /one -> b"hello "
reqwest 0.12 /one -> "hello "
tower-http 0.7.1 /split -> error: there are extra bytes after body has been decompressed
reqwest 0.12 /split -> error: error decoding response body <- there are extra bytes after body has been decompressed
- 同样的字节,两种结局。
/one只拿到hello,没有任何错误;/split报错。hyper 把每个 chunk 交成一个数据帧,第二段 gzip 是落在第一帧里还是单独成帧,决定了它是被解码器的缓冲吞掉,还是在解码器结束后作为「多出来的帧」被发现。网络上的分帧调用方控制不了,所以同一个上游可能时好时坏。 - reqwest 报的是另一句话。reqwest 0.12.28 的
gzipfeature 就是打开tower-http/decompression-gzip,它内部用的正是这段代码(依赖树里是 tower-http 0.6.11,这个检查 0.6.11 也有)。reqwest 把 body 错误包成Kind::Decode,Display只写error decoding response body,原文要顺着source()才看得到。只打印{e}的日志里看不到 gzip 的字样,见 reqwest 卷 error sending request for url。
出处
compression_utils.rs 第 249 行,在 WrapBody::poll_frame 解码器读完以后的分支里:
Some(Ok(frame)) => match frame.into_data() {
// Payload after the decompressor reported end-of-stream means
// the body and the compressed stream disagree about where the
// content ends.
Ok(data) if data.has_remaining() => Poll::Ready(Some(Err(
"there are extra bytes after body has been decompressed".into(),
))),
gzip 解码器没开 multiple_members(0.6.8 的 #621 关掉的),读完第一段就报告结束。这个检查只能看到「解码器结束之后才到达的帧」,同一帧里剩下的字节已经被读进了解码器的缓冲,WrapBody 不再检查它们。0.7.0 曾经把这个检查弄丢(非空帧原样转发),0.7.1 的 #712 加回来,经过见 响应解压 的「结尾:三次改动」。
怎么办
- 先确认是不是上游的问题:把原始字节存下来(关掉客户端的自动解压),用
zcat之类的命令行工具解一遍,它会把拼接的几段都解出来,和客户端拿到的长度一比就知道。HTTP 里一个Content-Encoding: gzip的 body 通常只有一段(#621 关掉多段支持的理由就是「HTTP context only uses one member」),出现多段多半是上游把几份压缩数据直接拼在了一起。修上游最干净。 - 改不了上游、又确实要多段:在客户端关掉自动解压(tower-http 不套
DecompressionLayer;reqwest 用.no_gzip()),拿到原始字节后用支持多段的解码器(flate2 的MultiGzDecoder)自己解。 - 静默截断那一半最危险:只有第二段恰好单独成帧时才有错误。对完整性有要求的下载,比对长度或校验和。
小结
- 解码器读完第一段 gzip 后,内层 body 又交出非空数据帧,就报
there are extra bytes after body has been decompressed;同一帧里的多余字节则被静默丢掉。 - 在真实连接上,结果取决于上游怎么分 chunk;reqwest 0.12 内部是同一段代码,错误显示为
error decoding response body,原文在source()里。 - 修上游,或者关掉自动解压、自己用多段解码器。
💬 关联讨论
发起讨论加载中...