Rust in Everything
基于 pingora-core 0.9.0

HTTP/1 服务端

主线位置:请求主线的第二步。HttpServerApp::process_new_http 拿到 Box<dyn IO> 后建一个 v1 的 HttpSession,read_request 读出并校验请求头,HttpProxy::process_request 才开始调钩子;转发前,逐跳头在这里定义的规则下被删掉;响应写完,reuse 决定这条下游连接能不能回到 keep-alive 循环。

flowchart TB
  rd["read_request_buf"] --> parse["httparse 解析"]
  parse -->|Partial| rd
  parse -->|"Token/Version 错"| esc["转义请求行再试"]
  esc --> parse
  parse -->|"其他错误"| bad["400"]
  parse -->|Complete| build["建 RequestHeader"]
  build --> tecl["TE+CL 删 CL"]
  tecl --> ka["respect_keepalive"]
  ka --> val["validate_request"]
  val -->|失败| bad
  val --> proxy["process_request"]
  proxy --> resp["写响应头"]
  resp --> reuse{"reuse"}
  reuse -->|"keep-alive"| rd
  reuse -->|"close 或多读"| fin["关连接"]

代码在 pingora-core/src/protocols/http/v1/server.rs(4595 行,前 1856 行是实现,后面全是测试)、common.rs(654 行,常量和头部判断)和 header.rs(460 行,0.9.0 新加的可取消写头状态机,proxy task 用,见双向转发)。请求目标校验在 protocols/http/authority.rs,逐跳头的规则在 upstreams/peer.rs 和 pingora-proxy/src/proxy_common.rs。body 怎么读写见 HTTP/1 消息体。

实验在 exp-pingora/proto-main(M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2):代理 127.0.0.1:18300(默认设置)和 18302(打开 pipelining)都转到 18301 上的原始 TCP 服务,它把收到的请求原样写进响应体,所以响应体就是「上游看到的字节」。客户端是一个 Python 脚本,直接往 socket 里写字节,读到对端关闭或空闲 1.5 秒为止,最后一行打印连接是怎么结束的。

HttpSession:一个连接上的一个请求

HttpSession 有三十多个字段,按用途分:

  • 连接:underlying_stream: Stream,以及 new 时从它收集的 digest。
  • 请求头:buf(原始字节,解析完冻结成 Bytes)、raw_header(头在 buf 里的范围)、request_header、preread_body(和请求头一起读进来的 body 开头)。
  • body:body_reader、body_writer 两个状态机,retry_buffer(重试时重放请求体)。
  • 连接复用:keepalive_timeout、keepalive_reuses_remaining、pipelining_enabled、pipelined_prefix、half_closed、abort_on_close。
  • 超时:read_timeout 默认 Some(60s),write_timeout、total_drain_timeout 默认 None。

一个 HttpSession 只服务一个请求。keep-alive 的下一个请求用 reuse 交出的 Stream 再 new 一个,HttpPersistentSettings 把超时、pipelining 开关和用户上下文带过去(process_new_http 里的循环)。

读请求头

read_request 的主循环:

        let mut buf = self
            .pipelined_prefix
            .take()
            .filter(|prefix| !prefix.is_empty())
            .unwrap_or_else(|| BytesMut::with_capacity(INIT_HEADER_BUF_SIZE));
        let mut already_read = buf.len();
        let mut skip_next_read = already_read != 0;
        let mut detached_suffix = None;
        loop {
            if already_read > MAX_HEADER_SIZE {
                /* NOTE: this check only blocks second read. The first large read is allowed
                since the buf is already allocated. The goal is to avoid slowly bloating
                this buffer */
                return Error::e_explain(
                    InvalidHTTPHeader,
                    format!("Request header larger than {MAX_HEADER_SIZE}"),
                );
            }
            // ...
            if skip_next_read {
                skip_next_read = false;
            } else if let Some(n) = self.read_request_buf(&mut buf, already_read).await? {
                already_read += n;
            } else {
                return Ok(None);
            }

            // Use loop as GOTO to retry escaped request buffer, not a real loop
            loop {
                let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS];
                let mut req = httparse::Request::new(&mut headers);
                let parsed = parse_req_buffer(&mut req, &buf);
                match parsed {
                    HeaderParseState::Complete(s) => {
                        // ...
                    }
                    HeaderParseState::Partial => {
                        break; /* continue the read loop */
                    }
                    HeaderParseState::Invalid(e) => match e {
                        // ...
                    },
                }
            }
        }

三个常量在 common.rs:MAX_HEADERS = 256(栈上 httparse::Header 数组的长度),INIT_HEADER_BUF_SIZE = 4096,MAX_HEADER_SIZE = 1048575(1 MiB 减 1)。每读一次就从头重新解析整个 buf,没有 hyper 那种「先扫一遍有没有空行」的快速路径。读之前先查已读字节数,所以限制落在「读之前已经超了」这一刻,不是精确到字节的。

read_request_buf 决定这次读等多久:keepalive_timeout 是 Timeout(d) 时用 d,超时算客户端走了,返回 Ok(None);是 Off 时用 read_timeout(默认 60 秒),超时报 ReadTimedout;是 Infinite 时不设超时,源码在这里留了一条 FIXME: this should only apply to reads between requests。读到 0 字节时,一个字节都还没读过就是 Ok(None)(keep-alive 连接上客户端关了,正常),读过一些就是 ConnectionClosed。

parse_req_buffer 默认调 httparse::Request::parse;开了 patched_http1 feature 则调 parse_unchecked,crates.io 上的 httparse 没有这个方法,要配一个打过补丁的 httparse 才编得过(见怎么读这一卷)。

实验:头部的几种上限

发送的请求响应代理日志里的原因
请求行 + Host + 256 个 X-Hn: v400,关连接too many headers
请求行 + Host + 255 个 X-Hn: v(正好 256 个头)200
头部共 1,040,040 字节,一次写完200,上游收到 1040040 字节
头部共 1,048,579 字节,一次写完400Request header larger than 1048575
头部共 1,100,040 字节,一次写完400,客户端看到 RST同上
先发 1,040,036 字节,停 300 ms 再发 60,004 字节400同上
X-A: 1\r\n 2(obs-fold 续行)400invalid header name
GET /x HTTP/9.9400invalid HTTP version

所有 InvalidHTTPHeader 在代理里都变成一个空体的 400,带 Connection: close(handle_new_request)。1,100,040 字节那次是 RST:pingora 回完 400 就关 socket,接收缓冲里还有没读的字节,内核于是发 RST 而不是 FIN。hyper 的默认上限是读缓冲 max_buf_size 约 408 KiB,超了回 431,见读写缓冲。

转义请求行

httparse 遇到请求目标里的空格、" 这类字节会报 Token。pingora 不直接拒绝:escape_illegal_request_line 用正则 ^\w+ (?P<uri>.+) HTTP/\d(?:\.\d)? 找出 URI,按 URI_ESC_CHARSET(控制字符加空格、<、>、")做百分号编码,重建 buf 再解析一次。转义前先把空行之后的字节(body、下一个 pipelined 请求)切到 detached_suffix 里,转义只重建请求头那一段。发 GET /a b"c HTTP/1.1,上游收到:

GET /a%20b%22c HTTP/1.1\r\nHost: a\r\n\r\n

HTTP/9.9 走的也是这条路(Version 错误同样触发转义),但 URI 里没有要转义的字节,重建出来的和原来一样,函数返回 None,于是报错。

零拷贝与大小写

解析完成后,req.headers 里的名字和值都是借用 buf 的切片。要把 buf 冻结成 Bytes,得先放掉这些借用。populate_headers 把每个头记成相对 buf 起点的偏移(KVRef),然后:

                        let buf = buf.freeze();

                        for header in header_refs {
                            let header_name = header.get_name_bytes(&buf);
                            let header_name = header_name.into_case_header_name();
                            let value_bytes = header.get_value_bytes(&buf);
                            // safe because this is from what we parsed
                            let header_value = unsafe {
                                http::HeaderValue::from_maybe_shared_unchecked(value_bytes)
                            };

                            request_header
                                .append_header(header_name, header_value)
                                .or_err(InvalidHTTPHeader, "while parsing request header")?;
                        }

值是 buf 的 Bytes::slice,不拷贝。名字转成 CaseHeaderName:RequestHeader 里除了 http::HeaderMap(名字一律小写),还有一张记原始拼写的表,写回线上时按原样输出(详见请求头与错误)。发一个 HTTP/1.0 请求,带 X-MiXeD-Case: 1 和 host: Example.COM,上游收到:

GET /a HTTP/1.1\r\nX-MiXeD-Case: 1\r\nhost: Example.COM\r\n\r\n

两个头的拼写原样保留。版本从 1.0 变成了 1.1:这是 proxy_h1 发上游前的 req.set_version(Version::HTTP_11),不是解析器做的。保留只对解析来的头有效,pingora 自己插入的头用 http 的常量名,是小写的,下面 WebSocket 那个实验里的 upgrade: websocket 就是。hyper 默认把头名全部小写,要保留得打开 preserve_header_case,见解析与编码。

校验:0.9.0 收紧的部分

请求头建好后、交给代理之前还有两步。第一步在 read_request 里:Transfer-Encoding 和 Content-Length 同时出现时删掉 Content-Length,并关掉这条连接的 keep-alive(RFC 9112 6.1)。第二步是 validate_request:

    pub fn validate_request(&self) -> Result<()> {
        let req_header = self.req_header();

        // Reject an ambiguous authority before anything routes on it.
        validate_request_authority(req_header)?;

        // Validate/reconcile Content-Length per RFC 9110 section 8.6 (hyper
        // parity): identical duplicates and comma-combined identical values are
        // accepted and collapsed; differing or unparseable values are rejected.
        // This is a no-op when Transfer-Encoding is present (TE overrides CL).
        super::common::validate_content_length(&req_header.headers)?;

        if req_header.headers.contains_key(TRANSFER_ENCODING) {
            // ...
            if req_header.version == http::Version::HTTP_10 {
                return Error::e_explain(
                    InvalidHTTPHeader,
                    "HTTP/1.0 requests cannot include Transfer-Encoding header",
                );
            }
            // ...
            if !self.is_chunked_encoding() {
                return Error::e_explain(InvalidHTTPHeader, "non-chunked final Transfer-Encoding");
            }
        }

        Ok(())
    }

Content-Length 和 Transfer-Encoding 的实验放在消息体那一页。validate_request_authority 是 0.9.0 新的:ffab830(「Reject ambiguous authorities on ingress and egress」)和 1e55128(「Split request-target classification into pingora-http」),都首次进入 0.9.0,CHANGELOG 把它们写进「HTTP ambiguity hardening」。规则是:

  • validate_request_authority_fields(HTTP/1 和 h2 共用):不许有两个 Host,Host 和 URI 的 authority 里不许有 @(http::Uri::host 会把 userinfo 去掉,路由时看到的主机和请求里写的不一样),两者都有时必须逐字节相等。
  • 绝对形式(GET http://a.example/x)的 authority 必须能解析、不能含 userinfo,有 Host 时必须和它相同。注释特意说明这比 RFC 9112 3.2.2 严:RFC 让代理用请求目标里的 authority 替换掉冲突的 Host,pingora 直接拒绝。
  • CONNECT 另有一套比对,但代理默认根本不接 CONNECT:handle_new_request 在 allow_connect_method_proxying 没开时回 405。

实测(代理日志开 debug):

请求响应原因
GET http://a.example/x,Host: b.example400Host header differs from request-target authority
GET http://a.example/x,Host: a.example200上游收到的请求行仍是 GET http://a.example/x HTTP/1.1
GET http://u@a.example/x400userinfo in request target
两个 Host400multiple Host header fields
CONNECT a.example:443405

绝对形式原样转给上游,这是 0.9.0 CHANGELOG 里的「Preserve non-origin-form request targets without mangling the URI」。

keep-alive

respect_keepalive 在请求头解析完时调用:

    pub fn respect_keepalive(&mut self) {
        if let Some(keepalive) = self.is_connection_keepalive() {
            if keepalive {
                let (timeout, _max_use) = self.get_keepalive_values();
                // TODO: respect max_use
                match timeout {
                    Some(d) => self.set_keepalive(Some(d)),
                    None => self.set_keepalive(Some(0)), // infinite
                }
            } else {
                self.set_keepalive(None);
            }
        } else if self.req_header().version == Version::HTTP_11 {
            self.set_keepalive(Some(0)); // on by default for http 1.1
        } else {
            self.set_keepalive(None); // off by default for http 1.0
        }
    }

Some(0) 是「无限」,None 是关(set_keepalive 把它们换成 KeepaliveStatus 的 Infinite 和 Off)。get_keepalive_values 本该解析 Keep-Alive: timeout=5, max=1000,函数体只有 // TODO: implement this parsing 和 (None, None),所以客户端的 Keep-Alive 头不起作用,请求一旦是 keep-alive,状态就是 Infinite。

这和等下一个请求的超时连在一起。新连接上的第一个 HttpSession 由 process_new 调 session.set_keepalive(Some(60)),状态是 Timeout(60s),read_request_buf 最多等 60 秒。读到请求后 respect_keepalive 把它改成 Infinite;reuse 之后,HttpPersistentSettings 记下的是这个改过的值,交给下一个会话。于是从第二个请求起,等请求的那次读没有超时,正是上面那条 FIXME 说的情况。实测两条连接同时开:一条连上后什么都不发,一条发一个 GET 收完响应后不再发:

silent: FIN after 60.0s, got 0 bytes
one: still open after 100.0s, got 236 bytes

第一条在 60 秒时被关掉,第二条 100 秒后还开着。0.8.0 的 respect_keepalive 和 process_new 是同样的写法;0.9.0 之后 main 上这两个文件没有改这一处。

Connection 头的解析在 parse_connection_header:值正好是 close、keep-alive、upgrade 之一走快速路径;否则按逗号拆开,只认 close 和 upgrade。慢路径里没有设 keep_alive 的分支,所以 Connection: keep-alive, X-Foo 在 HTTP/1.0 上不会打开 keep-alive,在 HTTP/1.1 上照默认打开。

写响应头时 prepare_response_header 无条件加 Date 和 Connection: keep-alive 或 close。几种情况会在这里把 keep-alive 关掉:请求体还没读完就要写响应(close_on_response_before_downstream_finish,默认开)、响应是 101、响应体是读到关闭为止(没有长度)。

最后是 reuse:

    pub async fn reuse(mut self) -> Result<Option<ReusableHttpStream>> {
        if !self.will_keepalive() {
            debug!("HTTP shutdown connection");
            self.shutdown().await;
            Ok(None)
        } else {
            self.drain_request_body().await?;
            if self.body_reader.has_bytes_overread() && !self.pipelining_enabled {
                debug!("bytes overread on request, disallowing reuse");
                Ok(None)
            } else {
                let pipelined_prefix = self
                    .pipelining_enabled
                    .then(|| self.take_body_overread())
                    .flatten()
                    .filter(|prefix| !prefix.is_empty());
                Ok(Some(ReusableHttpStream::new(
                    self.underlying_stream,
                    pipelined_prefix,
                )))
            }
        }
    }

不 keep-alive 就关;keep-alive 就先把没读完的请求体读完扔掉,再看有没有「读过头」的字节,也就是请求体之后、属于下一个请求的字节。

❗IMPORTANT
动脑筋:发一个不带 Connection 头的 HTTP/1.0 请求 GET /a HTTP/1.0,pingora 回的状态行是哪个版本?Connection 头是什么?连接是关掉还是留着?

pipelining:0.9.0 新加,默认关

客户端不等响应、一次写两个请求时,第二个请求的字节在读第一个请求时就进了缓冲区。默认设置下它们被当成「读过头」,reuse 返回 None。打开 set_pipelining_enabled 后,多读的字节作为 pipelined_prefix 交给下一个 HttpSession,read_request 看到前缀非空就先解析、不读 socket(上面代码里的 skip_next_read),因为前缀可能已经是一个完整的请求头,再读会一直等下去。实验代理在 early_request_filter 里调 session.set_pipelining_enabled(true)。同一段字节:

GET /p1 HTTP/1.1\r\nHost: a\r\n\r\nGET /p2 HTTP/1.1\r\nHost: a\r\n\r\n

默认端口 18300:

HTTP/1.1 200 OK
...
Connection: keep-alive

GET /p1 HTTP/1.1\r\nHost: a\r\n\r\n

-- 234 bytes; closed by server (FIN)

只回了 /p1,响应头里写着 keep-alive,连接随后被关掉,/p2 被丢掉。Connection 头写在 reuse 之前,那时还不知道会多读。打开 pipelining 的 18302:

HTTP/1.1 200 OK
...
GET /p1 HTTP/1.1\r\nHost: a\r\n\r\n
HTTP/1.1 200 OK
...
GET /p2 HTTP/1.1\r\nHost: a\r\n\r\n

-- 468 bytes; idle timeout (connection still open)

两个请求按顺序处理,/p2 要等 /p1 的响应写完才开始,各自独立选上游,没有并发。hyper 的服务端默认就这样串行处理 pipelined 请求,不需要打开什么,见 Dispatcher。

📝NOTE
冷知识:在 pipelining 之前,0.8.0 先学会了「不复用」

2f59535(「Prevent v1 reuse on body overread (pipeline)」,首次进入 0.8.0)之前,reuse 读完请求体就交出连接,读过头的字节留在旧会话的 body 缓冲里,跟着旧会话一起丢掉;它在代码里留的注释是「pingora 400s if pipelined requests are sent in the middle of another request」。这个提交让 reuse 在 has_bytes_overread() 时返回 None,关掉连接,「the pipelined request left unread, and the client can attempt to resend」。0.9.0 的 4a9a34c(「Support HTTP/1.1 request pipelining on the downstream session」)才把这些字节交给下一个会话;它的说明写「Default (off) is unchanged」,并引用了 issue #377 和 #673。之后的 bd054ba(「Avoid copying buffered HTTP/1 pipelines」,同样首次进入 0.9.0)把 preread_body 改成独立的 BytesMut,多读的字节一路切片传下去,不再拷贝。

逐跳头:0.9.0 起默认删

逐跳头(hop-by-hop header)只对一跳连接有意义,代理不该原样转发。0.9.0 之前 pingora 把下游的请求头几乎原样发给上游,删不删由应用在 upstream_request_filter 里自己处理。28c18e6(「Sanitize hop-by-hop headers on HTTP upstream requests」,首次进入 0.9.0)加了一套策略,CHANGELOG 把它列在「Potential Breaking Changes」里。

策略是 HttpPeer 上的 HttpUpstreamRequestPolicy,默认值四项全开:

impl Default for HttpUpstreamRequestPolicy {
    fn default() -> Self {
        Self {
            strip_hop_by_hop: true,
            strip_connection_nominated: true,
            reject_malformed_connection_nominations: true,
            h1_upgrade: H1UpgradePolicy::WebSocketOnly,
        }
    }
}

执行它的是 pingora-proxy 里的 sanitize_h1_upstream_request,在调 upstream_request_filter 之前,所以应用在过滤器里加回去的头不受影响(调用位置见双向转发)。顺序是:

  1. 解析 Connection 里点名的头(最多 10 个,超了报错)。点名 host、x-forwarded-for、x-forwarded-host、x-forwarded-proto 或以 : 开头的伪头,直接报 InvalidHTTPHeader;不是合法 token 的(比如带引号)也报错。
  2. 删掉被点名的头。
  3. strip_standard_hop_by_hop_headers 删十个:Keep-Alive、Proxy-Connection、Proxy-Authenticate、Proxy-Authorization、TE、Trailer、Transfer-Encoding、Connection、Upgrade、HTTP2-Settings。
  4. WebSocketOnly:请求是合法的 WebSocket 升级,就重新插入 Connection: Upgrade 和 Upgrade: websocket。

Transfer-Encoding 也被删了。应用过滤器之后,finalize_h1_upstream_request_framing 看到下游有 body、请求里既没有 Content-Length 也没有 Transfer-Encoding,就补一个 Transfer-Encoding: chunked。要回到旧行为用 HttpUpstreamRequestPolicy::preserve(),文档注释写着「RFC-non-compliant」。

实测,下游发:

GET /h HTTP/1.1\r\nHost: a\r\nConnection: keep-alive, X-Secret\r\nX-Secret: 1\r\nKeep-Alive: timeout=5\r\nTE: trailers\r\nProxy-Authorization: Basic eA==\r\nUpgrade: h2c\r\nHTTP2-Settings: AAAA\r\nX-Kept: 1\r\n\r\n

上游收到:

GET /h HTTP/1.1\r\nHost: a\r\nX-Kept: 1\r\n\r\n

Connection: Host 回 400,代理日志:protected header cannot be nominated by the Connection header, status: 400。这个错误发生在选好上游之后,所以日志级别是 error,带 Peer: addr: 127.0.0.1:18301,前面那些解析期的 400 只是 debug。一个带 Connection: Upgrade, X-Secret 的 WebSocket 请求,上游收到:

GET /ws HTTP/1.1\r\nHost: a\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nConnection: Upgrade\r\nupgrade: websocket\r\n\r\n

X-Secret 被删,Connection 和 Upgrade 是删掉后重新插入的,排到了最后;upgrade 是小写,因为插入时用的是 http::header::UPGRADE。

Expect: 100-continue 与响应头

is_expect_continue_req 只对 HTTP/1.1 生效,值必须是 100-continue(不区分大小写)。HttpSession 有 write_continue_response,但 pingora-proxy 里没有任何地方调用它:代理不替上游回 100,Expect 头原样转给上游,上游回的 100 再转回来。设了 ignore_info_resp(不转发 1xx)时,ignore_info_resp 仍然放行 101,以及请求带了 Expect 时的 100。

响应头由 http_resp_header_to_buf 序列化:状态行的版本取自 ResponseHeader(HTTP/2 会序列化失败),头按保存的大小写写出。写完立刻 flush 的只有两种:1xx,和没有 Content-Length 的响应(注释说这种可能是实时生成的)。init_body_writer 按状态码和方法选 body 写法:204、304、HEAD 一律当作长度 0,101 当作读到关闭为止,其余看响应头。

动脑筋答案

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 64
x-l4: peer=Some("127.0.0.1:60668") rtt=Some(0) est=Some(None) tls=None
Date: Thu, 29 Oct 2026 04:59:54 GMT
Connection: close

GET /a HTTP/1.1\r\nX-MiXeD-Case: 1\r\nhost: Example.COM\r\n\r\n

-- 257 bytes; closed by server (FIN)
  • 状态行是 HTTP/1.1:pingora 把上游响应头的版本原样写出,上游回的是 1.1。版本号只表示发送方支持到哪个版本,RFC 9110 允许这样回。
  • Connection: close:HTTP/1.0 请求没有 Connection: keep-alive,respect_keepalive 走最后一个分支,set_keepalive(None);prepare_response_header 按 will_keepalive() 写 close。
  • 连接被关掉:reuse 看到 will_keepalive() 为假,shutdown。同一个请求加上 Connection: keep-alive,响应变成 Connection: keep-alive,连接留着(实测 idle timeout (connection still open))。

小结

  • read_request 每读一次就用栈上 256 个槽的 httparse::Request 重新解析整个缓冲;超过 256 个头、读之前已超 1048575 字节、obs-fold、坏版本都是 400 加关连接。
  • 请求行里的空格和 " 先被百分号编码再解析,不直接拒绝。
  • 头的值是原始缓冲的零拷贝切片,名字的原始拼写由 CaseHeaderName 保存,转发时原样写出;pingora 自己插入的头是小写。
  • 0.9.0 拒绝重复 Host、userinfo、Host 与绝对形式请求目标不一致;绝对形式原样转发;CONNECT 默认 405。
  • keep-alive:1.1 默认开,1.0 要显式 keep-alive;客户端的 Keep-Alive 参数没有实现;reuse 在多读了字节时拒绝复用。
  • 新连接等第一个请求最多 60 秒;处理过一个 keep-alive 请求后状态变成 Infinite,之后的空闲等待没有超时(实测 100 秒仍开着)。
  • pipelining 是 0.9.0 的选项,默认关:只回第一个响应就关连接;打开后按顺序逐个处理。
  • 0.9.0 默认在转发前删十个逐跳头和 Connection 点名的头,点名受保护的头直接 400;preserve() 回到旧行为。

💬 关联讨论

发起讨论
加载中...