HTTP/1 消息体
主线位置:请求主线的「
proxy_h1双向转发请求体和响应体」。下游一侧,HttpSession的BodyReader把请求体从连接里切出来,BodyWriter给响应体加上分界;上游一侧的 v1 客户端用的是同两个类型,方向反过来。
stateDiagram-v2 [*] --> ToStart ToStart --> Partial: Content-Length 大于 0 ToStart --> Complete: 长度 0 或无 body ToStart --> Chunked: chunked ToStart --> UntilClose: 读到关闭 Partial --> Partial: 读到一部分 Partial --> Complete: 读满 Chunked --> Chunked: 一个数据块 Chunked --> ChunkedFinal: 读到 0 块 Chunked --> Complete: 0 块后紧跟空行 ChunkedFinal --> Complete: 读到空行 UntilClose --> Complete: 对端关闭 Partial --> Done: 出错或提前关闭 Chunked --> Done: 出错或提前关闭 ChunkedFinal --> Done: 出错或提前关闭
图是 ParseState 的状态,Done 表示「结束了,但是出了错」。代码在 pingora-core/src/protocols/http/v1/body.rs,4442 行,前 2027 行是实现,其余是测试。
实验在 exp-pingora/proto-main(M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2),环境和 HTTP/1 服务端 那一页相同:代理 127.0.0.1:18300 转到 18301 的原始 TCP 服务,上游把收到的请求原样写进响应体,并把每次 read 收到的字节记进日志。
读请求体之前:选哪种读法
请求头解析完、第一次读 body 时,init_body_reader 按 RFC 9112 6.3 选:
if self.was_upgraded() {
// if upgraded _post_ 101 (and body was not init yet)
// treat as upgraded body (pass through until closed)
self.body_reader.init_close_delimited_owned(preread_body);
} else if self.is_chunked_encoding() {
// if chunked encoding, content-length should be ignored
self.body_reader.init_chunked_owned(preread_body);
} else {
// ...
let cl = self.get_content_length().unwrap_or(None);
match cl {
Some(i) => {
self.body_reader.init_content_length_owned(i, preread_body);
}
None => {
// ...
self.body_reader.init_content_length_owned(0, preread_body);
}
}
}
请求永远不会「读到关闭为止」:没有长度也没有 chunked,就是长度 0。只有升级成功(101)之后,剩下的字节才按读到关闭处理。preread_body 是和请求头一起读进来的那部分字节,_owned 版本直接把这块 BytesMut 收下当 body 缓冲,不拷贝。读响应体的客户端一侧多一种情况:响应没有长度时是读到关闭为止。
is_chunked_encoding 只看最后一个 Transfer-Encoding 头的最后一个 token(is_chunked_encoding_from_headers)。Content-Length 由 content_length_parse_all 解析:所有 Content-Length 头、每个头里逗号分开的每个值都必须是纯数字(from_digits 不接受正负号)且彼此相等。这是 0.9.0 的 5bec405(「Reconcile Content-Length per RFC 9110 §8.6 across H1 and H2」),提交说明写「Match hyper's content-length parsing」,注释里直接链着 hyper v1.9.0 的 headers.rs。
BodyReader
BodyReader 只有六个字段:
pub struct BodyReader {
pub body_state: ParseState,
pub body_buf: Option<BytesMut>,
pub body_buf_size: usize,
rewind_buf_len: usize,
upstream: bool,
body_buf_overread: Option<BytesMut>,
}
body_buf_size 是 BODY_BUFFER_SIZE,64 KiB,每次最多读这么多。rewind_buf_len 是 preread_body 的长度:第一次 read_body 先交出这部分,不读 socket。body_buf_overread 存「读过头」的字节,也就是 body 结束之后的字节,下游一侧它们是下一个 pipelined 请求的开头,见HTTP/1 服务端。upstream 只影响两处:读定长 body 时是否按剩余长度截断读的大小,以及读过头时打 warn 还是 debug。
read_body 按状态分派,返回 BufRef,即 body_buf 里的一个范围,不拷贝:
match self.body_state {
PS::Complete(_) => Ok(None),
PS::Done(_) => Ok(None),
PS::Partial(_, _) => self.do_read_body(stream).await,
PS::Chunked(..) => self.do_read_chunked_body(stream).await,
PS::ChunkedFinal(..) => self.do_read_chunked_body_final(stream).await,
PS::UntilClose(_) => self.do_read_body_until_closed(stream).await,
PS::ToStart => panic!("need to init BodyReader first"),
}
定长
do_read_body 在下游一侧把读的大小限制在剩余长度以内(remaining.min(self.body_buf_size)),注释说「downstream should not discard remaining data if peer sent more」:body 后面的字节留在连接上,归下一个请求。上游一侧照读 64 KiB,多出来的打一条 Peer sent more data than expected。读到 0 字节而 body 还没完,状态变成 Done,报 ConnectionClosed:「Peer prematurely closed connection with N bytes of body remaining to read」。
chunked
do_read_chunked_body 的状态 Chunked(read, next_start, buf_end, expecting) 记着四个数:已读的 body 字节、缓冲里下一个块从哪开始、缓冲有效数据到哪、当前块还差多少字节要从 IO 读。每次调用至多交出一个块的数据。块头交给 parse_chunked_buf,它调 httparse::parse_chunk_size:
- 解析出
(payload_index, chunk_size)。chunk_size是u64,转usize、算块尾下标都用checked_*,溢出报INVALID_CHUNK。 - 数据之后的两个字节必须是
\r\n,由validate_crlf检查;缓冲里只到了\r时返回Ok(false),等下次读。 - 缓冲里块头还不完整(
Status::Partial)且已经超过PARTIAL_CHUNK_HEAD_LIMIT(8 KiB)时报「Chunk ext over limit」;没超过就把这段半截的块头挪到缓冲最前面,再读。 - 块大小是 0:后面紧跟
\r\n就直接Complete;否则进ChunkedFinal,由do_read_chunked_body_final一个 CR、一个 LF 地找连续的\r\n\r\n,中间的 trailer 字节只计数、丢弃,累计超过TRAILER_SIZE_LIMIT(64 KiB)报「Trailer size over limit」。
注意第三条的条件:只有块头跨了两次读时才检查长度。整个块头一次就在缓冲里,httparse 直接返回 Complete,扩展有多长都不管(httparse 在 ; 之后接受任何字节)。
parse_chunked_buf 里有一行注释链着 httparse 的 issue #149:「httparse does not treat zero-size chunk differently, it does not check that terminating chunk is 0 + double CRLF」。a88d048(「Fix chunked trailer end parsing」,首次进入 0.7.0)之前,读到 0\r\n 就算 body 结束,后面的 \r\n 和 trailers 都不看。它的说明是「the body reader does not validate the following CRLF required to close the body. Additionally, that current parsing scheme will not consider trailers before the end CRLF. Now the trailers are considered but simply discarded.」这个提交加了 ChunkedFinal 状态、TRAILER_SIZE_LIMIT 和 parse_trailers_end,改动 1283 行,大部分是测试。
实验:22 种请求体
Python 客户端往 18300 直接写字节。|SLEEP| 处停 300 ms,|SHUT| 表示写完后半关闭(发 FIN)。「上游」一列是 18301 收到的字节;400 都带 Connection: close,原因取自代理的 debug 日志。
| 编号 | 请求体 | 下游收到 | 原因 / 上游收到 |
|---|---|---|---|
| b01 | 5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n | 200 | 上游分四次读到:请求头、5\r\nhello\r\n、6\r\n world\r\n、0\r\n\r\n |
| b02 | 5;foo=bar\r\nhello\r\n0\r\n\r\n | 200 | 上游收到 5\r\nhello\r\n0\r\n\r\n,扩展没了 |
| b03 | 5\r\nhelloXX0\r\n\r\n | 400 | Invalid chunked encoding: XX was not CRLF |
| b04 | zz\r\n... | 400 | Invalid chunked encoding: InvalidChunkSize |
| b05 | 块大小 ffffffffffffffff | 400 | chunk size overflows buffer index |
| b06 | 块大小 17 位十六进制 | 400 | InvalidChunkSize(httparse 最多接受 16 位) |
| b07 | 9000 字节的扩展,一次写完 | 200 | 上游收到 5\r\nhello\r\n0\r\n\r\n |
| b07b | 同上,扩展写到 8500 字节时停 300 ms | 400 | Chunk ext over limit,客户端看到 RST |
| b07c | 60000 字节的扩展,一次写完 | 200 | 同 b07 |
| b08 | 0\r\nX-T: 1\r\n\r\n(带 trailer) | 200 | 上游收到 0\r\n\r\n,trailer 没了 |
| b09 | 70000 字节的 trailer | 400 | Trailer size over limit |
| b10 | 只用 LF:5\nhello\n0\n\n | 400 | InvalidChunkSize |
| b11 | Content-Length: 10,发 5 字节后 FIN | 0 字节,连接关闭 | Peer prematurely closed connection with 5 bytes of body remaining to read;上游只收到请求头 |
| b12 | chunked,发完一块就 FIN | 0 字节,连接关闭 | Connection prematurely closed without the termination chunk, read 5 bytes |
| b13 | Content-Length: 3 和 chunked 并存 | 200,Connection: close | 上游收到的请求里没有 Content-Length |
| b14 | HTTP/1.0 加 chunked | 400 | HTTP/1.0 requests cannot include Transfer-Encoding header |
| b15 | Transfer-Encoding: gzip | 400 | non-chunked final Transfer-Encoding |
| b16 | Transfer-Encoding: gzip, chunked | 200 | 上游收到的是 Transfer-Encoding: chunked |
| b17 | Content-Length: 5, 5 | 200 | 上游收到的头仍是 Content-Length: 5, 5 |
| b18 | Content-Length: 5 和 Content-Length: 6 | 400 | invalid Content-Length header |
| b19 | Content-Length: +5 | 400 | 同上 |
| b20 | Content-Length: 5,实际发 helloXYZ | 200,Connection: keep-alive,随后 FIN | XYZ 读过头,pipelining 没开,连接不复用 |
| b21 | 块头 10\r\n 拆成 1 和 0\r\n 两次发,结尾的 \r 和 \n 也拆开 | 200 | 上游收到 10\r\n0123456789abcdef\r\n0\r\n\r\n |
| b22 | 18302(开了 pipelining)上 POST 带 5 字节 body,紧跟一个 GET | 两个 200 | 两个请求依次到上游 |
几条要单独说:
- 转发的是重新编码的 chunked。 下游的块边界、扩展、trailer 都不会原样到上游:
BodyReader交出的是去掉分界的数据,上游一侧的BodyWriter再按自己的块重新编码(下一节)。b16 里gzip, chunked变成了chunked,是因为逐跳头清理删了Transfer-Encoding,再由finalize_h1_upstream_request_framing补一个chunked;body 本身原样转发,gzip这层编码的标记丢了。 - 请求体出错时,上游已经收到了请求头。 b03 到 b12,上游日志里都是先读到一个完整的请求头,然后连接被关掉(
closed mid-body, have 0 bytes)。代理不等 body 读完才发请求,所以请求体的错误发生在双向转发的半路,上游连接只能扔掉。b11、b12 下游一个字节都没收到,代理日志里这两条的status: 0:下游已经半关闭,不再回错误响应。 - 扩展长度的限制只在块头被拆开时生效。 b07 和 b07c 的扩展远超 8 KiB,一次到达,照样通过;b07b 只有 9000 字节,但在 8500 字节处被拆成两次读,就是 400。hyper 对扩展的限制是整个 body 累计 16 KiB,trailer 默认 16 KiB,见body 的解码与编码。
Content-Length: 5, 5只在判断分界时被合并。 校验通过,但请求头原样转发。实验上游对这个值的处理是简化的:解析失败就当作 0。它于是把hello留在了这条池化的上游连接上,下一个请求 b20 的响应体开头多出了hello(上表 b20 那一行的响应体是helloPOST /b20 ...)。这是一个故意写松的上游;它说明的是,只要上游对这个值的理解和 pingora 不同,同一条连接上的分界就会错位。
PARTIAL_CHUNK_HEAD_LIMIT 是 8 KiB。哪几个会得到 400?BodyWriter
BodyMode 有五种:ToSelect、ContentLength(total, written)、ChunkedEncoding(written)、UntilClose(written)、Complete(written)。下游响应由 init_body_writer 选(204、304、HEAD 当长度 0,101 当读到关闭),其余交给 init_body_writer_comm:chunked 优先,其次 Content-Length,都没有就读到关闭为止。
do_write_body(定长)写多了会截断,打 Trying to write data over content-length;写满才 flush,中间的小块留在 L4 的写缓冲里(下游连接上是 1460 字节,上游连接上是 0,见 L4 流)。BodyWriter 前面的注释解释了这个取舍:缓冲能省系统调用,但会拖慢实时通信,所以「only allow buffering when the body size is known ahead」。finish 时写少了,do_finish_body 返回 PREMATURE_BODY_END。chunked 的 do_write_chunked_body:
BM::ChunkedEncoding(written) => {
let chunk_size = buf.len();
let chuck_size_buf = format!("{:X}\r\n", chunk_size);
let mut output_buf = Bytes::from(chuck_size_buf).chain(buf).chain(&b"\r\n"[..]);
stream
.write_all_buf(&mut output_buf)
.await
.or_err(WriteError, "while writing body")?;
stream.flush().await.or_err(WriteError, "flushing body")?;
self.body_mode = BM::ChunkedEncoding(written + chunk_size);
Ok(Some(chunk_size))
}
每个块单独 flush:没有长度的 body 被当作实时数据。块头、数据、\r\n 用 Buf::chain 串起来写,不拼接。finish 再写 0\r\n\r\n(do_finish_chunked_body)。
0.9.0 还给 BodyWriter 加了一套可取消的写法:send_body_task 先登记要写的数据,write_current_body_task 在 select! 里被取消后可以从 WriteState 记下的进度接着写。它给 proxy task 用,见双向转发。
一个空块就是结束
上面的代码没有检查 buf 是否为空。空的 buf 编码出来是 0\r\n\r\n,正好是 chunked 的结束标记;之后 finish 再写一次。h2 的下游可以发一个空的、带 END_STREAM 的 DATA 帧来结束请求体,转到 HTTP/1 上游时就走到这里。实验:在 h2c 端口(18303,见 HTTP/2)上用 h2 crate 发 POST /first,先发 body-data,再发一个空的 DATA 帧结束流,然后在同一条 h2 连接上发 GET /second。上游日志:
[up#3] read 69: POST /first HTTP/1.1\r\nHost: 127.0.0.1\r\nTransfer-Encoding: chunked\r\n\r\n
[up#3] read 19: 9\r\nbody-data\r\n0\r\n\r\n
[up#3] request 88 bytes, path /first
[up#3] read 5: 0\r\n\r\n
[up#3] request 5 bytes, path
[up#3] closed by peer, 0 unparsed bytes:
[up#4] accepted
[up#4] read 41: GET /second HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n
上游收到两个 0\r\n\r\n。第二个被实验上游当成了一个新请求,它回了一个响应;这条连接这时已经回到连接池,池子的空闲监视读到了这些字节,打一条 Data received on idle client connection, close it(pingora_pool,warn),把它关掉。/second 于是开了新连接 up#4(代理日志:No reusable connection found for addr: 127.0.0.1:18301)。客户端这边两个请求都是 200,看不出问题。一个严格的上游会在第二个 0\r\n\r\n 上报错或者等待,表现取决于上游怎么解析这 5 个字节。main 上的 10a8345(2026-09-23,「Ignore empty writes in chunked encoding」)在 do_write_chunked_body 和可取消版本里都加了「chunk_size == 0 就直接返回 Ok(Some(0))」,注释写「finish() is the only place that ends the body」;它在 0.9.0 之后,还没有发版。
动脑筋答案
| 请求 | 结果 |
|---|---|
| b07:9000 字节,一次写完 | 200 |
| b07b:9000 字节,8500 字节处停 300 ms | 400,代理日志 Chunk ext over limit |
| b07c:60000 字节,一次写完 | 200 |
- 一次写完的两个,请求头和 body 一起进了 64 KiB 的 L4 读缓冲,
read_request读进来的preread_body里块头是完整的,httparse::parse_chunk_size直接返回Complete,长度不检查。 - b07b 第一次读只有 8500 多字节的半截块头,
Status::Partial,而缓冲长度已经超过 8192,于是报错。 - 所以
PARTIAL_CHUNK_HEAD_LIMIT限的是「为一个没收全的块头攒多少字节」,不是扩展的长度。
小结
- 请求体三种读法:chunked、定长、长度 0;读到关闭只用于升级之后和没有长度的响应。
Content-Length的所有值必须是纯数字且相等(0.9.0 对齐 hyper)。 BodyReader每次至多交出一个块,返回缓冲里的范围,不拷贝;下游定长 body 不多读,多读的字节留给下一个请求。- chunked 的块头交给
httparse::parse_chunk_size;数据后的\r\n、结尾的空行由 pingora 自己检查(0.7.0 起);扩展和 trailer 被丢弃,trailer 上限 64 KiB,扩展的 8 KiB 上限只在块头跨两次读时生效。 - 请求体出错时上游已经收到了请求头,上游连接被扔掉;下游半关闭时不回错误响应。
BodyWriter定长写满才 flush、多写截断,chunked 每块 flush;0.9.0 的do_write_chunked_body把空数据编码成0\r\n\r\n,h2 下游的空END_STREAM帧会让上游收到两个结束块,main 上10a8345修了,未发版。