请求头与错误
主线位置:请求主线上请求头和响应头的样子。HTTP/1 服务端把解析出来的每个头
append_header进一个RequestHeader;钩子里改头用insert_header/remove_header;发往上游时header_to_h1_wire按原样的拼写写出去,响应方向对称。缓存主线上,要存进缓存的响应头由pingora-header-serde压成一段字节,命中时再还原。
flowchart TB wire["线上的头"] --> parse["httparse 解析"] parse --> app["append_header"] app --> hm["HeaderMap 小写名到值"] app --> cm["CaseMap 小写名到原拼写"] hook["钩子 insert 或 remove"] --> hm hook --> cm hm --> zip["两表 zip 同序迭代"] cm --> zip zip --> out["header_to_h1_wire"] hm --> ser["header-serde 压缩"]
pingora-http 的 src/ 2278 行,lib.rs 一个文件 1925 行(其中 870 多行是测试),另有 case_header_name.rs(118 行)和 0.9.0 新拆出来的 authority.rs(请求目标里的 authority 分类,process_request 那一侧用)。pingora-header-serde 的 src/ 673 行。pingora-error 的类型在错误、重试与故障转移里讲,这里只在用到时提一句:本页所有构造头的方法出错时都返回 ErrorType::InvalidHTTPHeader。
实验平台:M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0(对照组单独依赖 pingora-http 0.8.0),实验 crate 在 exp-pingora/comp-header 和 exp-pingora/comp-http08,没有起服务,tokio 也就不涉及 1.53.2 的问题。
两张平行的表
pub struct RequestHeader {
base: ReqParts,
header_name_map: Option<CaseMap>,
raw_target: RawTarget,
// whether we send END_STREAM with HEADERS for h2 requests
send_end_stream: bool,
}
base 是 http crate 的 request::Parts(方法、URI、版本、HeaderMap、extensions),HeaderMap 里的名字一律小写。原始拼写存在 CaseMap 里,它就是 HMap<CaseHeaderName>,也是一个 http::HeaderMap,键是同一个小写名,值是 CaseHeaderName(一段 Bytes)。定义上方的注释把设计讲全了:
/* an ordered header map to store the original case of each header name
HMap({
"foo": ["Foo", "foO", "FoO"]
})
The order how HeaderMap iter over its items is "arbitrary, but consistent".
Hopefully this property makes sure this map of header names always iterates in the
same order of the map of header values.
This idea is inspaired by hyper @nox
*/
type CaseMap = HMap<CaseHeaderName>;
写回线上时不按键去查拼写,而是把两张表各自迭代、按位置配对。case_header_iter:
name_map.into_iter().flat_map(|name_map| {
name_map
.iter()
.zip(value_map.iter())
.map(|((h1, name), (h2, value))| {
// in case the header iteration order changes in future versions of HMap
assert_eq!(h1, h2, "header iter mismatch {}, {}", h1, h2);
(name, value)
})
})
header_to_h1_wire 就是遍历它,逐行写 拼写: 值\r\n。同名的多个值(比如两个 Set-Cookie)在两张表里都是同一个键下的多个值,按位置一一对上,所以每一行的拼写都能各自保留。
两张表的迭代顺序为什么能对上?http 的 HeaderMap 把条目放在一个 Vec 里,按插入顺序迭代,删除用 swap_remove(把最后一个条目挪到被删的位置,http-1.5.0 的 remove_found)。迭代顺序因此只取决于对它做过的操作序列。append_header_value、insert_header_value 和 remove_header 三个函数对两张表做完全相同的操作,这就是全部的不变量:两张表的操作历史必须一模一样。注释里的「in case the header iteration order changes in future versions of HMap」防的是 http crate 改实现;用户绕开这三个函数直接动 HeaderMap,破坏的是同一个不变量。
header_name_map 是 Option:RequestHeader::build 建一张,build_no_case 和 From<request::Parts> 不建。h2 服务端收到的请求头就是 request_header.into() 来的,HTTP/2 的头名本来就是小写。没有这张表时,写出去的名字由 titled_header_name_str 决定。
问: pingora 自己加的头,Content-Length 是首字母大写的,upgrade 却是小写的,为什么不一致?
答: 用 http::header::* 常量当名字时,IntoCaseHeaderName for HeaderName 会查 titled_header_name_str,它只认识 12 个:Accept-Ranges、Age、Cache-Control、Connection、Content-Type、Content-Encoding、Content-Length、Date、Transfer-Encoding、Host、Server、Set-Cookie,其余(包括 upgrade)原样用 HeaderName 的小写。源码里还留着两条 TODO:「add more const header here」和「automatically upper case the first letter?」。用字符串字面量当名字(insert_header("X-Foo", ..))时,写出去就是字面量的拼写。
0.9.0 去掉了 DerefMut
0.8.0 里 RequestHeader 和 ResponseHeader 同时实现了 Deref<Target = Parts> 和 DerefMut(0.8.0 的 impl DerefMut for RequestHeader),req.headers.insert(..)、resp.headers.remove(..)、req.uri = .. 都能编译,改的是 base 里的字段,header_name_map 和别的状态不知道。
提交 4d7061b(2026-06-19)的标题就是原因:「Remove DerefMut from RequestHeader and ResponseHeader due to internal invariant violation which triggers panics or silent truncation」。没有 PR 号。它删掉两个 impl DerefMut,补了 extensions_mut()(方法、URI、版本、状态码在 0.8.0 就有 set_* 方法,extensions 原来只能靠 DerefMut 改),在两个类型的文档里各加一个 compile_fail 文档测试(request.headers.remove("user-agent") / response.headers.remove("server")),再把 pingora-proxy 一个测试里的 req.method = Method::HEAD 改成 req.set_method(Method::HEAD)。首次发布在 0.9.0,CHANGELOG 列在「Potential Breaking Changes」:「RequestHeader and ResponseHeader no longer implement DerefMut because unrestricted mutation could violate internal invariants.」
「panics or silent truncation」在 0.8.0 上能直接看到。comp-http08 只依赖 pingora-http = "=0.8.0",建一个有三个头的 ResponseHeader(insert_header 依次放 Content-Type、X-Request-Id、Cache-Control),然后绕开方法直接改 base(debug 构建,release 结果相同):
let mut req = RequestHeader::build("GET", b"/caf\xe9", None).unwrap();
req.uri = "/new".parse().unwrap();
// ...
let mut resp = base();
resp.headers.remove("content-type");
println!("{:?}", wire(&resp));
输出(中间一段留给下面的「动脑筋」,用 ... 标出):
uri via DerefMut: uri=/new raw_path="/caf�"
...
remove via DerefMut: headers.len()=2
thread 'main' (5742792) panicked at .../pingora-http-0.8.0/src/lib.rs:755:17:
assertion `left == right` failed: header iter mismatch content-type, cache-control
left: "content-type"
right: "cache-control"
- 删头 → panic。 值表删
content-type用的是swap_remove,最后的cache-control挪到了第一位:值表是[cache-control, x-request-id],名字表还是[content-type, x-request-id, cache-control]。zip 配出的第一对名字不同,assert_eq!失败。这是case_header_iter里的assert_eq!,不是debug_assert!,release 一样 panic;在代理里,它发生在把请求头写向上游的那一刻。 - 改 URI → 请求行还是旧的。 路径不是合法 UTF-8 时,0.8.0 把原始字节另存在
raw_path_fallback里,set_uri会清掉它,直接赋值req.uri不会。raw_path()还是/caf\xe9,而 HTTP/1 客户端写请求行用的正是raw_path()(0.8.0 的http_req_header_to_wire),上游收到的还是旧路径。0.9.0 把这份字节换成了raw_target: RawTarget(ee78ceb,absolute-form 和 CONNECT 的目标也原样保存),set_uri同样负责把它重置为RawTarget::FromUri,所以「URI 只能通过方法改」这条不变量在 0.9.0 更宽了。 - 没有
header_name_map的头(build_no_case、h2)不受第一种影响,没有第二张表可以对不上。
ResponseHeader 执行 resp.headers.insert("x-added", "1".parse().unwrap()),headers.len() 是 4。header_to_h1_wire 写出来几行?会 panic 吗?想想 zip 遇到长短不一的两个迭代器怎么办,答案在文末。0.9.0 上的报错
同样的三种写法在 0.9.0 上编不过(out/derefmut-0.9.txt、out/fields-0.9.txt,摘录):
error[E0596]: cannot borrow data in dereference of `ResponseHeader` as mutable
--> src/bin/derefmut.rs:7:3
|
7 | resp.headers.insert("x-added", "1".parse().unwrap());
| ^^^^^^^^^^^^ cannot borrow as mutable
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `ResponseHeader`
...
error[E0594]: cannot assign to data in dereference of `RequestHeader`
--> src/bin/fields.rs:7:3
|
7 | req.uri = "/b".parse().unwrap();
| ^^^^^^^ cannot assign
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `RequestHeader`
调方法的 headers.insert / remove / get_mut 报 E0596,给字段赋值(req.method = ..、req.uri = ..、resp.status = ..)报 E0594。对应的改法:
| 0.8.0 的写法 | 0.9.0 |
|---|---|
req.headers.insert(k, v) / append | req.insert_header(k, v)? / append_header,返回 Result |
req.headers.remove(k) | req.remove_header(k) |
req.headers.get_mut(k) 原地改值 | 没有对应的方法,读出来再 insert_header |
req.method = m | req.set_method(m) |
req.uri = u | req.set_uri(u),或 set_raw_path(bytes)? |
req.version = v / resp.version = v | set_version(v) |
resp.status = s | resp.set_status(s)? |
req.extensions.insert(x) | req.extensions_mut().insert(x) |
只读的写法不受影响:Deref 还在,&mut ResponseHeader 上调 resp.headers.contains_key(name) 时,编译器自动走 Deref。comp-header 的 gwcompat 照抄本站网关的三种读法(resp.headers.contains_key(name) 决定要不要补头、req.headers.get("host")、req.uri.path_and_query()),在 0.9.0 上编译通过,输出:
https://Example.COM/a?b=1
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
本站网关改头只用 insert_header、append_header、remove_header,DerefMut 这一条不会让它编不过;它升级到 0.9.0 时其他的编译错误和行为差异见本站网关。
追加也会失败
0.9.0 的 append_header_value 里还有一处和不变量有关的改动:
// store the original case in the map
if let Some(name_map) = name_map {
// Use the non-panicking `try_append`: the infallible `append` calls
// `.expect("size overflows MAX_SIZE")` internally, which would abort the
// process if the case map ever exceeded `http`'s `MAX_SIZE` (1 << 15).
name_map
.try_append(header_name.clone(), case_header_name)
.or_err(InvalidHTTPHeader, "header name map size overflows MAX_SIZE")?;
}
// Non-panicking `try_append` for the same reason as the case map above.
value_map.try_append(header_name, value).or_err(
InvalidHTTPHeader,
"header value map size overflows MAX_SIZE",
)
0.8.0 的这两行是 name_map.append(..) 和 Ok(value_map.append(header_name, value))。http 的 HeaderMap::append 在超过容量上限时 expect("size overflows MAX_SIZE")。e21646b(2026-06-22,「Avoid panic in append_header_value via try_append」)换成 try_append,函数签名本来就返回 Result,所以对调用方不算破坏性变更;提交说明写的是这样会「aborts the process」。首次发布在 0.9.0,CHANGELOG:「Avoid a process abort while appending oversized header maps by returning an InvalidHTTPHeader error.」comp-http08 和 comp-header 的 many 往一个 ResponseHeader 里追加 4 万个不同名的头:0.8.0 打出 panicked at .../http-1.5.0/src/header/map.rs:1433:14: size overflows MAX_SIZE: MaxSizeReached;两边都停在下标 24576(第 24577 个),0.9.0 返回的是 Err:InvalidHTTPHeader context: header name map size overflows MAX_SIZE cause: max size reached。24576 是 MAX_SIZE(2¹⁵)的 3/4。名字表先失败、先返回,值表没有动,两张表仍然一致。
ResponseHeader 另外存一个 reason_phrase: Option<String>,不设时 get_reason_phrase 返回状态码的标准短语。
pingora-header-serde:给缓存存响应头
缓存条目的元数据里要存一份响应头。HeaderSerde 的做法是先把它写成 HTTP/1.1 线上格式,再用 zstd 压缩:
serialize:resp_header_to_buf写状态行(h2 的响应也写成HTTP/1.1)、header_to_h1_wire、空行,再以COMPRESS_LEVEL= 3 压缩。deserialize:解压,交给httparse解析(打开了「冒号前允许空格」和「接受 obs-fold」两个宽松选项,和 HTTP/1 客户端读上游响应时一致),逐个append_header(header.name.to_string(), ..)建回ResponseHeader,拼写原样还原。- 压缩和解压的 zstd 上下文、中间缓冲区都是
ThreadLocal的,每个线程一份,字典(CDict/DDict)共享。 - 字典可选。
dict::train把一个目录下所有文件当样本训练,crate 自带一个trainer二进制做这件事;仓库里的样本是samples/test/下 7 个几乎一样的 nginx 响应头(换行是\n,而序列化出来的是\r\n)。
pingora-cache 用一个全局的 HEADER_SERDE,第一次用到时初始化;在那之前调 set_compression_dict_path 或 set_compression_dict_content 就带字典,否则打一条「no header compression dictionary loaded」警告、不用字典。元数据的其他部分怎么序列化见缓存键与元数据。
实验:压缩比
crate 文档开头说「serialize http response header to about 1/3 of its original size (HTTP/1.1 wire format) with trained dictionary」。comp-header 的 serde_ratio 用那 7 个样本训练出一个字典,再压四个响应头:样本 1 本身;一个同类但不在训练集里的 nginx JS 响应;一个 HTML 页面加一组安全头和一条 CSP(按本站网关会加的那几个头拼的,CSP 是示例值);一个只有四个头的 JSON 接口响应。wire 是序列化前的字节数,括号里是压缩后占它的比例(release,out/serde_ratio.txt):
dictionary trained from 7 upstream samples: 1595 bytes
case wire no dict with dict
sample 1 (in training set) 450 303 (67%) 95 (21%)
nginx js, held out 312 246 (79%) 123 (39%)
html page + security headers 551 373 (68%) 395 (72%)
small json api 133 126 (95%) 90 (68%)
round trip: reason before=Some("Fine") after=Some("OK"), headers after="x-MiXeD-Case: 1\r\n"
no dict: serialize 3.67 us, deserialize 2.48 us (html case)
with dict: serialize 1.90 us, deserialize 1.55 us (html case)
- 「三分之一」只对和训练样本同类的头成立。 训练集里的那个压到 21%,同类的新响应 39%。和样本不像的 HTML 页面,带字典反而比不带大(395 对 373 字节)。
- 不带字典,短的头几乎压不动。 四个头的 JSON 响应 133 字节压完还有 126 字节。
- 带字典也更快。 序列化 1.90 µs 对 3.67 µs,反序列化 1.55 µs 对 2.48 µs(10 万次取 5 轮最快,
uptime负载 5 左右)。 - 原因短语不保存。
resp_header_to_buf写状态行用的是status.canonical_reason(),上游给的200 Fine存进去、读出来是200 OK;名字的大小写(x-MiXeD-Case)保留。到 main 的84f21ee为止这一行没变。
所以要用字典,就拿自己站点真实的响应头训练;拿仓库自带的 7 个 nginx 样本去压别的站点,可能比不压还大。
动脑筋答案
0.8.0 上那段被 ... 省掉的输出:
insert via DerefMut: headers.len()=4
"Content-Type: text/html\r\nX-Request-Id: abc\r\nCache-Control: no-store\r\n"
写出三行,不 panic,x-added 不见了。HeaderMap::insert 一个新名字是追加到条目 Vec 的末尾,值表变成四个、名字表还是三个;zip 在较短的那个迭代器结束时就停,前三对名字都对得上,assert_eq! 一次也没失败,第四个头就这样被悄悄丢掉。这就是提交标题里的「silent truncation」:headers.len() 说有 4 个头,发出去的请求只有 3 个,没有任何日志。
小结
RequestHeader/ResponseHeader=http的Parts+ 一张同样是HeaderMap的CaseMap,写回线上时两张表按位置 zip,名字不同就assert_eq!panic。- 不变量是「两张表的操作历史完全相同」:
append_header/insert_header/remove_header同时改两张表;HeaderMap的迭代顺序只由操作序列决定(追加到末尾,删除用swap_remove)。 - 0.8.0 的
DerefMut允许绕开它:删头导致 panic,加头被悄悄丢掉,改uri留下旧的原始路径。0.9.0 的4d7061b删掉DerefMut,改用set_*方法和extensions_mut();只读的headers.get、contains_key照样能编译。 - 没有
CaseMap的头(h2、build_no_case)写出去时只有 12 个常见名字首字母大写。 pingora-header-serde把响应头写成 HTTP/1.1 文本再 zstd(级别 3)压缩;带上同类样本训练的字典能压到 21% 到 39%,不同类的头带字典反而变大;原因短语不保存。pingora-error的类型和重试标记在错误、重试与故障转移。