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: v | 400,关连接 | too many headers |
请求行 + Host + 255 个 X-Hn: v(正好 256 个头) | 200 | |
| 头部共 1,040,040 字节,一次写完 | 200,上游收到 1040040 字节 | |
| 头部共 1,048,579 字节,一次写完 | 400 | Request 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 续行) | 400 | invalid header name |
GET /x HTTP/9.9 | 400 | invalid 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.example | 400 | Host header differs from request-target authority |
GET http://a.example/x,Host: a.example | 200 | 上游收到的请求行仍是 GET http://a.example/x HTTP/1.1 |
GET http://u@a.example/x | 400 | userinfo in request target |
两个 Host | 400 | multiple Host header fields |
CONNECT a.example:443 | 405 |
绝对形式原样转给上游,这是 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 就先把没读完的请求体读完扔掉,再看有没有「读过头」的字节,也就是请求体之后、属于下一个请求的字节。
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。
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 之前,所以应用在过滤器里加回去的头不受影响(调用位置见双向转发)。顺序是:
- 解析
Connection里点名的头(最多 10 个,超了报错)。点名host、x-forwarded-for、x-forwarded-host、x-forwarded-proto或以:开头的伪头,直接报InvalidHTTPHeader;不是合法 token 的(比如带引号)也报错。 - 删掉被点名的头。
strip_standard_hop_by_hop_headers删十个:Keep-Alive、Proxy-Connection、Proxy-Authenticate、Proxy-Authorization、TE、Trailer、Transfer-Encoding、Connection、Upgrade、HTTP2-Settings。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()回到旧行为。