Rust in Everything
基于 pingora-core 0.9.0

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 在 ; 之后接受任何字节)。

📝NOTE
冷知识:0.7.0 之前,pingora 不检查最后那个空行

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 日志。

编号请求体下游收到原因 / 上游收到
b015\r\nhello\r\n6\r\n world\r\n0\r\n\r\n200上游分四次读到:请求头、5\r\nhello\r\n、6\r\n world\r\n、0\r\n\r\n
b025;foo=bar\r\nhello\r\n0\r\n\r\n200上游收到 5\r\nhello\r\n0\r\n\r\n,扩展没了
b035\r\nhelloXX0\r\n\r\n400Invalid chunked encoding: XX was not CRLF
b04zz\r\n...400Invalid chunked encoding: InvalidChunkSize
b05块大小 ffffffffffffffff400chunk size overflows buffer index
b06块大小 17 位十六进制400InvalidChunkSize(httparse 最多接受 16 位)
b079000 字节的扩展,一次写完200上游收到 5\r\nhello\r\n0\r\n\r\n
b07b同上,扩展写到 8500 字节时停 300 ms400Chunk ext over limit,客户端看到 RST
b07c60000 字节的扩展,一次写完200同 b07
b080\r\nX-T: 1\r\n\r\n(带 trailer)200上游收到 0\r\n\r\n,trailer 没了
b0970000 字节的 trailer400Trailer size over limit
b10只用 LF:5\nhello\n0\n\n400InvalidChunkSize
b11Content-Length: 10,发 5 字节后 FIN0 字节,连接关闭Peer prematurely closed connection with 5 bytes of body remaining to read;上游只收到请求头
b12chunked,发完一块就 FIN0 字节,连接关闭Connection prematurely closed without the termination chunk, read 5 bytes
b13Content-Length: 3 和 chunked 并存200,Connection: close上游收到的请求里没有 Content-Length
b14HTTP/1.0 加 chunked400HTTP/1.0 requests cannot include Transfer-Encoding header
b15Transfer-Encoding: gzip400non-chunked final Transfer-Encoding
b16Transfer-Encoding: gzip, chunked200上游收到的是 Transfer-Encoding: chunked
b17Content-Length: 5, 5200上游收到的头仍是 Content-Length: 5, 5
b18Content-Length: 5 和 Content-Length: 6400invalid Content-Length header
b19Content-Length: +5400同上
b20Content-Length: 5,实际发 helloXYZ200,Connection: keep-alive,随后 FINXYZ 读过头,pipelining 没开,连接不复用
b21块头 10\r\n 拆成 1 和 0\r\n 两次发,结尾的 \r 和 \n 也拆开200上游收到 10\r\n0123456789abcdef\r\n0\r\n\r\n
b2218302(开了 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 不同,同一条连接上的分界就会错位。
❗IMPORTANT
动脑筋:b07、b07b、b07c 三个请求,扩展分别是 9000、9000、60000 字节,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 ms400,代理日志 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 修了,未发版。

💬 关联讨论

发起讨论
加载中...