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 没有列出这条行为变化;来龙去脉见错误、重试与故障转移的冷知识。
问: 上游日志写着 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 时,只对上游能去重的请求放行。