ProxyHttp
主线位置:请求主线里「读完请求头」之后的每一步。
HttpProxy把一个请求从读完头到写完响应拆成固定的几段,每段调用ProxyHttp的一个或几个方法;用户写的代码全在这些方法里。这一页只讲 trait 本身:有哪些钩子、各在哪个阶段、默认做什么、返回值怎么被解读。驱动它们的代码在 process_request 和双向转发。
flowchart TB
start([读完请求头]) --> early[early_request_filter]
early --> dmod[下游模块请求头]
dmod --> rf{request_filter}
rf -->|"Ok(true)"| log[logging]
rf -->|"Ok(false)"| cache[缓存分支]
cache -->|命中| log
cache -->|未命中| puf{proxy_upstream_filter}
puf -->|"Ok(false)"| log
puf -->|"Ok(true)"| peer[upstream_peer]
peer --> conn{连接上游}
conn -->|失败| ftc[fail_to_connect]
conn -->|成功| ctu[connected_to_upstream]
ctu --> urf[upstream_request_filter]
urf --> duplex[双向转发]
duplex -->|出错| ewp[error_while_proxy]
ftc -->|可重试| peer
ewp -->|可重试| peer
ftc -->|不重试| ftp[fail_to_proxy]
ewp -->|不重试| ftp
ftp --> log
duplex -->|完成| log
图是按 0.9.0 的源码画的,不是照搬 docs/user_guide/phase_chart.md;两者的差别在「对照 phase_chart」一节。duplex 那一格里面还有请求体和响应的十来个钩子,在下一节的表里。
trait 定义在 pingora-proxy/src/proxy_trait.rs(757 行,几乎全是默认实现和文档注释)。实验在 exp-pingora/proxy-lab(M2 Max、macOS 27、rustc 1.99.0,release,tokio 钉在 1.53.2,原因见怎么读这一卷)。
一个 trait,42 个方法
ProxyHttp 只有两样东西必须写:关联类型 CTX 和它的构造函数 new_ctx,加上 upstream_peer(请求发去哪)。其余 40 个方法都有默认实现。trait 头上是:
#[cfg_attr(not(doc_async_trait), async_trait)]
pub trait ProxyHttp {
/// The per request object to share state across the different filters
type CTX;
/// Define how the `ctx` should be created.
fn new_ctx(&self) -> Self::CTX;
async fn 靠 async_trait 宏改写成返回 Pin<Box<dyn Future + Send>> 的普通方法,每调用一次就有一次堆分配。doc_async_trait 这个 cfg 只在生成文档时打开(pingora-proxy/Cargo.toml 的 [package.metadata.docs.rs]),让文档里显示原生的 async fn。
数一下每个版本 trait 里的方法(grep -cE '^ (async )?fn '):
| 版本 | 方法数 | 其中 async |
|---|---|---|
| 0.1.0 | 25 | 10 |
| 0.4.0 | 31 | 12 |
| 0.6.0 | 32 | 12 |
| 0.8.0 | 36 | 16 |
| 0.9.0 | 42 | 17 |
0.9.0 比 0.8.0 多出的 6 个是 init_upstream_modules、adjust_upstream_modules(上游模块,见上游模块与自定义转发)、persist_connection_context、on_connection_reuse(在同一个下游 keep-alive 连接的前后两个请求之间传数据)、suppress_proxy_warn_log(压掉重试时的 warn 日志)、purge_action(缓存清除时是删除还是标记过期)。
按阶段分组
下表按调用的先后分组,「同步」一列是方法本身有没有 async。行号链接指向方法签名。
| 阶段 | 钩子 | 同步 | 默认实现 |
|---|---|---|---|
| 建服务时 | init_downstream_modules | 是 | 加一个关闭状态的 ResponseCompressionBuilder::enable(0) |
init_upstream_modules | 是 | 什么都不加;要 upstream_modules feature | |
| 连接复用 | on_connection_reuse | 是 | 空;只对 HTTP/1 keep-alive,在 early_request_filter 之前 |
| 请求头 | early_request_filter | 否 | Ok(()) |
allow_spawning_subrequest | 是 | false | |
request_filter | 否 | Ok(false),继续 | |
| 缓存 | request_cache_filter、cache_key_callback、is_purge、purge_action、purge_response_filter、cache_hit_filter、cache_miss、cache_vary_filter、response_cache_filter、cache_not_modified_filter、range_header_filter、should_serve_stale | 除 cache_hit_filter 外都是 | 默认不开缓存;cache_key_callback 默认 unimplemented!,开了缓存就必须实现。见缓存状态机 |
| 去不去上游 | proxy_upstream_filter | 否 | Ok(true),去 |
upstream_peer | 否 | 必须实现 | |
| 连接 | connected_to_upstream | 否 | Ok(()) |
fail_to_connect | 是 | 原样返回错误 | |
| 发请求 | upstream_request_filter | 否 | Ok(()) |
request_body_filter | 否 | Ok(());每块请求体一次 | |
| 响应头 | adjust_upstream_modules | 否 | Ok(());要 upstream_modules feature |
upstream_response_filter | 否 | Ok(());写缓存之前 | |
response_filter | 否 | Ok(());缓存之后,命中也调用 | |
| 响应体 | upstream_response_body_filter | 是 | Ok(None) |
response_body_filter | 是 | Ok(None) | |
| 响应尾 | upstream_response_trailer_filter | 是 | Ok(()) |
response_trailer_filter | 否 | Ok(None) | |
| 出错 | error_while_proxy | 是 | 非幂等方法或重试缓冲被截断就不重试,见错误、重试与故障转移 |
fail_to_proxy | 否 | 按错误类型和来源回 502 / 400 / 500 | |
| 收尾 | logging | 否 | 空 |
persist_connection_context | 是 | None | |
| 写日志时 | request_summary、suppress_error_log、suppress_proxy_warn_log | 是 | 方法和头摘要 / false / false |
| 隐藏 | custom_forwarding、downstream_custom_message_proxy_filter、upstream_custom_message_proxy_filter | 否 | #[doc(hidden)],自定义协议用 |
加起来:new_ctx 和 upstream_peer 2 个必须实现,缓存 12 个,其余 28 个。17 个 async 里,cache_hit_filter 是缓存组唯一的一个。
实测调用顺序
proxy-lab/src/bin/hooks.rs 实现了 15 个钩子,每个钩子打一行「距 new_ctx 多少毫秒」。上游是 python3 -m http.server(GET)和一个手写的 TCP 服务(POST)。一次 GET 和一次 100000 字节的 POST(curl -H 'Expect:' --data-binary @body100k):
[ 0.2ms] /index.html early_request_filter
[ 0.5ms] /index.html request_filter
[ 0.6ms] /index.html request_cache_filter
[ 0.6ms] /index.html proxy_upstream_filter
[ 0.6ms] /index.html upstream_peer
[ 1.0ms] /index.html connected_to_upstream reused=false
[ 1.3ms] /index.html upstream_request_filter
[ 1.5ms] /index.html request_body_filter len=0 eos=true
[ 3.0ms] /index.html upstream_response_filter status=200
[ 3.1ms] /index.html response_filter status=200
[ 3.3ms] /index.html upstream_response_body_filter len=15 eos=true
[ 3.4ms] /index.html response_body_filter len=15 eos=true
[ 3.4ms] /index.html logging status=200 err=None
...
[ 0.5ms] /post connected_to_upstream reused=false
[ 0.5ms] /post upstream_request_filter
[ 0.7ms] /post request_body_filter len=3940 eos=false
[ 0.7ms] /post request_body_filter len=44900 eos=false
[ 0.8ms] /post request_body_filter len=51160 eos=true
[ 1.3ms] /post upstream_response_filter status=200
[ 1.4ms] /post response_filter status=200
[ 1.4ms] /post upstream_response_body_filter len=3 eos=true
[ 1.5ms] /post response_body_filter len=3 eos=true
[ 2.8ms] /post logging status=200 err=None
三点和直觉不一样:
- 没开缓存,
request_cache_filter也照样被调用。它是缓存分支的第一步(proxy_cache),默认实现什么都不做,所以缓存保持关闭。 - 没有请求体的 GET 也会进一次
request_body_filter,body是None、end_of_stream是true。原因是下游半边在请求体为空时主动往管道里送一个结束标记(见双向转发)。 - 请求体按读到的块调用,三块的大小(3940、44900、51160)是 socket 每次读到多少,不是固定值。
对照 phase_chart
docs/user_guide/phase_chart.md 和 phase.md 里是同一张 mermaid 图。逐条和 0.9.0 的代码对:
- 图上没有
proxy_upstream_filter。phase.md的正文有它的说明,图里request_filter直接连到upstream_peer。代码里它在缓存分支之后、upstream_peer之前(process_request)。缓存的十几个钩子图上也没有,phase.md的「Cache filters」一节是「To be documented」。 request_body_filter画在「send request to upstream」之前。 实际上请求头先发出去(proxy_1to1的write_request_header),然后才进入双向转发,请求体一块一块过request_body_filter,和读响应同时进行。上面 POST 的日志也是这个顺序。- 图上「any response filter error」都进
error_while_proxy。 只有在proxy_to_upstream里、拿到上游连接之后的错误才进它,包括upstream_request_filter和各个响应钩子返回的错误。early_request_filter、request_filter、proxy_upstream_filter的错误走handle_error,直接调fail_to_proxy;upstream_peer返回的错误两个都不经过(proxy_to_upstream第一个match)。 phase.md写的 feature 名是adjust_upstream_modules。 实际的 feature 叫upstream_modules(pingora-proxy/Cargo.toml和pingora/Cargo.toml),adjust_upstream_modules是方法名。- 「Most filters are async functions」。 0.9.0 是 42 个里 17 个。响应体和响应尾的四个钩子、
fail_to_connect、error_while_proxy都是同步的。 - 「Every request will end up in this final phase」(说
logging)。 在handle_new_request里被拒的请求不会:畸形请求头回 400、CONNECT回 405,这时还没有CTX,任何钩子都不调用。见 process_request 的实验。
trait 自己的文档注释也有一句对不上:「If any of the filters returns Result::Err, the request will fail」。request_cache_filter 返回 Err 只打一条 warn,请求照常继续(proxy_cache 里那个 // TODO: handle this error);HTTP/2 上游的 response_trailer_filter 返回 Err 也只记一条 error 日志,按没有尾部处理(h2_response_filter)。HTTP/1 上游根本不调用 response_trailer_filter:h1_response_filter 把 Trailer 任务原样放过,旁边的注释是「TODO: support trailers for h1」。
问: upstream_response_filter 和 response_filter 都能改响应头,为什么要两个?
答: 为了缓存。upstream_response_filter 在写缓存之前调用,改动会被存进缓存;缓存命中时直接从缓存出响应,它不会再被调用。response_filter 在缓存之后调用,命中和未命中都会经过。不开缓存时两个前后紧挨着(上面日志里相差 0.1 ms),用哪个都一样;开了缓存,「每个下游各不相同」的头(比如带请求 ID 的头)只能放在 response_filter。两个 body 钩子同理。
CTX:每个请求一个
new_ctx 在每个请求开始时调用一次(process_new_http 里,读完请求头之后),之后以 &mut Self::CTX 传给每个钩子;只有 allow_spawning_subrequest、request_summary、两个 suppress_*、is_purge、purge_action 和 persist_connection_context 拿的是 &Self::CTX。同一个下游 keep-alive 连接上的第二个请求会得到一个全新的 CTX。要把东西带到下一个请求,0.9.0 加了一对钩子:上一个请求结束时 persist_connection_context 返回一个 Box<dyn Any + Send + Sync>,下一个请求的 on_connection_reuse 收到它。只对 HTTP/1 有效,HTTP/2 每个流本来就是独立的。
几乎所有 async 钩子都带 where Self::CTX: Send + Sync。async_trait 生成的 future 要求 Send,future 里拿着 &mut CTX,所以 CTX 必须 Send;Sync 是 trait 统一加的约束。
Result<bool>:两个钩子,两种极性
有两个钩子用 bool 决定要不要往下走,意思正好相反。
request_filter 的 Ok(true) 表示「我已经回过响应了,结束」,Ok(false) 表示继续。process_request 里对应的代码:
match self.inner.request_filter(&mut session, &mut ctx).await {
Ok(response_sent) => {
if response_sent {
// TODO: log error
self.inner.logging(&mut session, None, &mut ctx).await;
self.cleanup_sub_req(&mut session);
let mut persistent_settings = HttpPersistentSettings::for_session(&session);
// ...
return session
.downstream_session
.finish()
.await
.ok()
.flatten()
.map(|s| ReusedHttpStream::from_reusable_stream(s, persistent_settings));
}
/* else continue */
}
框架不检查「是不是真的写了响应」,只是调 logging,然后把下游连接交回去复用。
proxy_upstream_filter 的 Ok(true) 表示「继续去上游」,Ok(false) 表示不去。不去的时候框架会补位:如果钩子自己没写响应(session.response_written().is_none()),就写一个静态的 BAD_GATEWAY。
在 hooks.rs 里:/short-true 让 request_filter 返回 Ok(true) 但什么都不写;/deny 让 proxy_upstream_filter 返回 Ok(false);/err400 照 docs/user_guide/errors.md 的写法,把一个 InvalidHTTPHeader 用 or_err(HTTPStatus(400), ...) 包一层返回。后两个的 curl 输出:
--- curl /err400
HTTP/1.1 400 Bad Request
Server: Pingora
Date: Thu, 29 Oct 2026 05:01:10 GMT
Content-Length: 0
Cache-Control: private, no-store
Connection: close
[curl exit 0]
--- curl /deny
HTTP/1.1 502 Bad Gateway
Server: Pingora
Content-Length: 0
Cache-Control: private, no-store
Date: Thu, 29 Oct 2026 05:01:10 GMT
Connection: keep-alive
[curl exit 0]
两个都是框架生成的空响应,差在最后一行:fail_to_proxy 走 respond_error,它总是关掉 keep-alive;BAD_GATEWAY 是普通的 write_response_header_ref,连接可以复用。/err400 的错误日志把整条错误链打了出来:
[2026-10-29T05:01:10Z ERROR pingora_proxy] Fail to filter request: HTTPStatus context: Missing required headers cause: InvalidHTTPHeader context: No x-token, status: 400, GET /err400, Host: 127.0.0.1:18500
errors.md 那段示例的最后一行是 Ok(true):校验通过之后返回「已经回过响应」。照抄的话,每个合法请求都会落进下面这个动脑筋的情形。
request_filter 返回 Ok(true) 却什么都没写,curl 会看到什么?hooks.rs 的 /short-true:request_filter 直接 Ok(true)。用 curl -s -i --max-time 3 http://127.0.0.1:18500/short-true 请求,curl 的退出码是多少?服务端的钩子日志有几行?
同步钩子
17 个 async、25 个同步。同步的这些大致四类:
- 建服务时只调一次的:
init_*_modules。 - 只做判断、不需要等 IO 的:缓存组的大部分、
suppress_*、request_summary、allow_spawning_subrequest。 - 在热路径上、每块数据调一次的:
upstream_response_body_filter、response_body_filter、upstream_response_trailer_filter。 - 出错时只改写错误标记的:
fail_to_connect、error_while_proxy。
源码没有写为什么这几个是同步的。能看到的是它们怎么弥补「不能 .await」:两个响应体钩子返回 Result<Option<Duration>>,返回 Some(d) 时,框架在调用之后替你 time::sleep(d).await(h1_response_filter 里,h2 同样)。这是给响应限速用的。hooks.rs 的 /big 让 response_body_filter 对每块返回 100 ms,上游是一个 262144 字节的文件,连跑三次:
code=200 size=262144 time=0.519735
code=200 size=262144 time=0.519990
code=200 size=262144 time=0.515357
日志里响应体分成 5 块(65536 × 4 加最后一块,最后一块 eos=true 也有数据),每块之后睡 100 ms,合起来 0.5 s 多一点。fail_to_connect 是同步的,所以想在重试之间退避,只能把 sleep 放进 async 的 upstream_peer,pingora-proxy/examples/backoff_retry.rs 就是这么写的。
0.9.0 之后,main 上的 018abe0(「feat: make response filter functions asynchronous in pingora」)把 upstream_response_body_filter、upstream_response_trailer_filter、response_body_filter 改成了 async,6cc56b6 给它们补上 CTX: Send + Sync。截至 84f21ee 还没有发版。
动脑筋答案
--- curl /short-true
[curl exit 28]
[ 0.0ms] /short-true early_request_filter
[ 0.0ms] /short-true request_filter
[ 0.1ms] /short-true logging status=0 err=None
- curl 退出码 28 是超时:3 秒内一个字节都没收到。
- 服务端只有三行。
logging里response_written()是None(打印成status=0),err也是None,框架认为这是一次正常结束的请求,没有错误日志。 - 然后
finish()把下游连接交回 keep-alive 循环,服务端开始等这个连接上的下一个请求;curl 还在等第一个响应。两边都在等对方,直到 curl 自己的超时(这里是 3 秒)。服务端不会替它收场:读完请求头时respect_keepalive已把 HTTP/1.1 连接的 keep-alive 设成无限,apps/mod.rs的 60 秒只管第一个请求之前;裸 socket 实测 130 秒后还开着(见常见坑 19、常见坑 11)。
小结
ProxyHttp0.9.0 有 42 个方法:必须实现的是new_ctx和upstream_peer,12 个缓存钩子,17 个async;0.9.0 新增 6 个。- 钩子按「请求头 → 缓存 → 去不去上游 → 连接 → 发请求 → 响应头 → 响应体 → 出错 → 收尾」排;
request_body_filter在请求头发出之后、和读响应同时进行。 phase_chart少了proxy_upstream_filter和缓存钩子,把请求体画在发请求之前,把所有错误都连到error_while_proxy;phase.md的 feature 名写错了。request_filter的Ok(true)是「已回应、结束」,框架不检查;只返回不写会让客户端一直等。proxy_upstream_filter的Ok(false)是「不去」,没写响应时框架补一个可复用的 502。- 响应体钩子是同步的,靠返回
Duration让框架代为sleep;main 上已经改成async,未发版。