Rust in Everything
基于 pingora-proxy 0.9.0

Upstream ConnectionClosed ... retry: false

主线位置:请求主线第六、七步之间。连接器从池里拿出一条复用的上游连接,请求写出去,读响应头时对端已经关了;process_request 的重试循环要决定换一条连接再试,还是回 502。重试标记的机制见错误、重试与故障转移,空闲连接什么时候被发现已关闭见连接池。

flowchart TB
  get["池里取到连接"] --> send["写请求"]
  send --> read["读响应头"]
  read -->|对端已关| err["ReusedOnly"]
  err --> ewp["error_while_proxy"]
  ewp -->|GET| retry["新连接再发"]
  ewp -->|"POST 0.9.0"| bad["502"]
  ewp -->|"POST 0.8.0"| twice["新连接再发 POST"]

实验 crate exp-pingora/pitb-lab,bin p18_post,脚本 tools/p18.sh;0.8.0 用本站网关的两份拷贝 exp-pingora/pitb-gw(原样,只改端口)和 pitb-gwfix(加了下面的改法),脚本 tools/p18gw.sh。全部 debug;M2 Max、macOS 27、rustc 1.99.0;0.9.0 那边 tokio 钉在 1.53.2(原因见怎么读这一卷),网关拷贝用自己锁文件里的 pingora 0.8.0、tokio 1.52.3。

上游是 tools/flaky_upstream.py:每条连接只回答第一个请求(keep-alive),读到第二个请求就关连接,什么都不回。这就是「池里的连接已经被上游关掉、而代理不知道」最常见的样子。空闲连接被关时 idle_poll 通常能先发现并把它清出池子,这个坑出在关闭和新请求撞在一起的那一刻。

现象

0.9.0。 代理(19330)只实现 upstream_peer、logging,外加一个和默认实现相同的 error_while_proxy;三次独立的 curl 依次发 GET /g1、GET /g2、POST /p1:

http://127.0.0.1:19330/g1 -> 200
http://127.0.0.1:19330/g2 -> 200
-d x=1 http://127.0.0.1:19330/p1 -> 502
## upstream
conn#1 req#1 GET /g1 HTTP/1.1
conn#1 req#2 GET /g2 HTTP/1.1
conn#1 closing without response
conn#2 req#1 GET /g2 HTTP/1.1
conn#2 req#2 POST /p1 HTTP/1.1
conn#2 closing without response
## proxy
mode=default
upstream_peer #1 GET /g1
logging GET /g1 attempts=1 status=Some(200)
upstream_peer #1 GET /g2
[2026-10-29T06:40:36Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:19331, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, tries: 1, retry: true, GET /g2, Host: 127.0.0.1:19330
upstream_peer #2 GET /g2
logging GET /g2 attempts=2 status=Some(200)
upstream_peer #1 POST /p1
[2026-10-29T06:40:36Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:19331, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, status: 502, tries: 1, retry: false, POST /p1, Host: 127.0.0.1:19330
logging POST /p1 attempts=1 status=Some(502)

GET /g2 在复用的连接上失败,换新连接成功,客户端无感;POST /p1 同样的失败,直接 502。

0.8.0(本站网关的拷贝,TLS 19332 → 上游 19334)。 同样三个请求:

GET /g1 -> 200
GET /g2 -> 200
POST /p1 -> 200
## upstream
conn#1 req#1 GET /g1 HTTP/1.1
conn#1 req#2 GET /g2 HTTP/1.1
conn#1 closing without response
conn#2 req#1 GET /g2 HTTP/1.1
conn#2 req#2 POST /p1 HTTP/1.1
conn#2 closing without response
conn#3 req#1 POST /p1 HTTP/1.1
## gateway
[2026-10-29T06:41:23Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:19334, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, tries: 1, retry: true, GET /g2, Host: 127.0.0.1:19332
[2026-10-29T06:41:23Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:19334, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, tries: 1, retry: true, POST /p1, Host: 127.0.0.1:19332

客户端拿到 200,上游却收到了两次 POST /p1(conn#2 req#2 和 conn#3 req#1)。这里上游第一次什么都没处理;如果它处理完、写响应之前连接断了,同一个下单请求就执行了两次。本站网关就是这个版本,见在本站中。

出处

读响应头时对端关闭,v1 客户端把错误标成 RetryType::ReusedOnly:「连接是复用的才能重试」。它必须在 error_while_proxy 里落定,0.9.0 的默认实现:

        let mut e = e.more_context(format!("Peer: {}", peer));
        if !session.req_header().method.is_idempotent() || session.as_ref().retry_buffer_truncated()
        {
            e.set_retry(false);
        } else {
            e.retry.decide_reuse(client_reused);
        }
        e

0.8.0 的 error_while_proxy 只有 e.retry.decide_reuse(client_reused && !session.as_ref().retry_buffer_truncated()),不看方法。改动是 6fa3835(「Update default error_while_proxy retry conditions」,首次进入 0.9.0),CHANGELOG 0.9.0 没有列出这条行为变化;来龙去脉见错误、重试与故障转移的冷知识。

📝NOTE
没有蠢问题

问: 上游日志写着 closing without response,它根本没处理这个 POST,为什么 0.9.0 不敢重发?

答: 代理看到的只有「请求写出去了,响应头一个字节都没读到,连接关了」(bytes already read: 0)。上游是没收到、收到没处理、还是处理完了没来得及回,这三种情况在代理这边是同一个错误。对 GET 来说哪种都可以重发;对 POST,只要有第三种的可能,重发就可能让副作用发生两次。能分辨的只有上游自己,所以下面的修法把这件事交给一个上游认得的幂等键。

修法

0.8.0(本站网关):把 0.9.0 的默认策略抄进去,diff(out/p18-gwfix.diff):

+  /// pingora 0.8.0 的默认实现在复用连接上出错时连 POST 也重试;照 0.9.0 的默认策略只重试幂等方法。
+  fn error_while_proxy(
+    &self,
+    peer: &HttpPeer,
+    session: &mut Session,
+    e: Box<pingora_core::Error>,
+    _ctx: &mut (),
+    client_reused: bool,
+  ) -> Box<pingora_core::Error> {
+    let mut e = e.more_context(format!("Peer: {peer}"));
+    if !session.req_header().method.is_idempotent() || session.as_ref().retry_buffer_truncated() {
+      e.set_retry(false);
+    } else {
+      e.retry.decide_reuse(client_reused);
+    }
+    e
+  }

pitb-gwfix 上同样三个请求:

GET /g1 -> 200
GET /g2 -> 200
POST /p1 -> 502
## upstream
...
conn#2 req#2 POST /p1 HTTP/1.1
conn#2 closing without response
## gateway
...
[2026-10-29T06:41:25Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:19334, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, status: 502, tries: 1, retry: false, POST /p1, Host: 127.0.0.1:19332

上游只收到一次 POST,客户端拿到 502,由客户端决定要不要重试。

0.9.0:502 是正确的保守行为。如果上游支持幂等键(同一个键的请求只执行一次),可以只对带键的 POST 放行重试:

fn error_while_proxy(&self, peer: &HttpPeer, session: &mut Session, e: Box<Error>, _ctx: &mut u32, client_reused: bool) -> Box<Error> {
  let mut e = e.more_context(format!("Peer: {peer}"));
  let req = session.req_header();
  let keyed = req.headers.contains_key("idempotency-key");
  if (!req.method.is_idempotent() && !keyed) || session.as_ref().retry_buffer_truncated() {
    e.set_retry(false);
  } else {
    e.retry.decide_reuse(client_reused);
  }
  e
}

MODE=fix,依次 GET /g1、不带键的 POST /p1、带键的 POST /k1、带键的 POST /k2:

http://127.0.0.1:19330/g1 -> 200
-d x=1 http://127.0.0.1:19330/p1 -> 502
-d x=1 -H Idempotency-Key: k1 http://127.0.0.1:19330/k1 -> 200
-d x=1 -H Idempotency-Key: k2 http://127.0.0.1:19330/k2 -> 200
## upstream
conn#1 req#1 GET /g1 HTTP/1.1
conn#1 req#2 POST /p1 HTTP/1.1
conn#1 closing without response
conn#2 req#1 POST /k1 HTTP/1.1
conn#2 req#2 POST /k2 HTTP/1.1
conn#2 closing without response
conn#3 req#1 POST /k2 HTTP/1.1
...

/k2 落在复用的连接上失败,换 conn#3 重发成功;上游收到两次 /k2,靠键去重。retry_buffer_truncated() 那一条不能删:请求体超过 64 KiB 时缓冲里没有完整的请求体,重发不了。

怎么确认没中招

  • 看 Cargo.lock 里 pingora-proxy 的版本。0.8.0 及以前,没有覆盖 error_while_proxy 的代理都会重发 POST。
  • 日志里搜 retry: true, POST 和 retry: true, PATCH:每一条都是一次非幂等请求的重发。
  • 0.9.0 上搜 retry: false, POST 加 ConnectionClosed,这是被挡下的那些;数量多时,看上游的 keep-alive 超时是不是比代理池里连接的空闲时间短;给 HttpPeer 设一个比上游短的 idle_timeout,让代理先关空闲连接,能减少和上游关连接撞上的机会。

小结

  • 复用连接上读不到响应头是 ReusedOnly,error_while_proxy 决定重不重试。
  • 0.9.0 默认只重试幂等方法,POST 回 502;0.8.0 默认不看方法,POST 被发两次,本站网关在 0.8.0 上实测如此。
  • 修法:0.8.0 把 0.9.0 的默认实现抄进 error_while_proxy;0.9.0 需要重试 POST 时,只对上游能去重的请求放行。

💬 关联讨论

发起讨论
加载中...