Rust in Everything
基于 pingora-proxy 0.9.0

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.02510
0.4.03112
0.6.03212
0.8.03616
0.9.04217

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」。

📝NOTE
没有蠢问题

问: 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):校验通过之后返回「已经回过响应」。照抄的话,每个合法请求都会落进下面这个动脑筋的情形。

❗IMPORTANT
动脑筋: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)。

小结

  • ProxyHttp 0.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,未发版。

💬 关联讨论

发起讨论
加载中...