Rust in Everything
基于 pingora-error 0.9.0

Retry is not decided

主线位置:请求主线的重试循环。连上上游以后出的错先经过 error_while_proxy,再回到 process_request 的循环里,由 error.retry() 决定要不要再试一次。三种重试标记和谁会设它们,见错误、重试与故障转移。

flowchart TB
  read["读响应头 对端已关"] --> ro["retry = ReusedOnly"]
  ro --> ewp["自己的 error_while_proxy"]
  ewp -->|原样返回| loop["循环里 error.retry"]
  loop --> panic["panic Retry is not decided"]
  ewp -->|decide_reuse| dec["Decided true 或 false"]
  dec --> ok["重试或 502"]

实验 crate exp-pingora/pitb-lab,bin p21_ewp,脚本 tools/p21.sh(debug);M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷)。代理在 19360;上游 tools/flaky_upstream.py 在 19361,每条连接只回答第一个请求,读到第二个就关连接,用它制造「复用的上游连接读不到响应头」。

现象

想给上游错误加一行日志或一个指标,覆盖了 error_while_proxy,记完原样返回:

fn error_while_proxy(
  &self,
  peer: &HttpPeer,
  session: &mut Session,
  mut e: Box<Error>,
  _ctx: &mut (),
  client_reused: bool,
) -> Box<Error> {
  eprintln!("error_while_proxy {} reused={client_reused}: {e} retry={:?}", session.req_header().uri, e.retry);
  e
}

三次独立的 curl 依次发 GET /g1、/g2、/g3。/g2 拿到的是池里那条已经回答过 /g1 的连接:

/g1 200 exit=0
/g2 000 exit=52
/g3 200 exit=0
mode=bug
logging /g1 status=Some(200)
error_while_proxy /g2 reused=true: Upstream ConnectionClosed context: while reading response headers, bytes already read: 0 retry=ReusedOnly

thread 'Pingora HTTP Proxy Service' (5882965) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-error-0.9.0/src/lib.rs:77:17:
Retry is not decided
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
logging /g3 status=Some(200)
  • panic 只结束 /g2 这个请求所在的任务,curl 拿到空回复(退出码 52),/g3 正常,进程还活着。
  • /g2 没有 logging 行,也没有 Fail to proxy 的 error 日志:panic 发生在两者之前。
  • 这条路径只有「上游连接是复用的、对端关了」才走得到;本地测试多半每个请求一条新连接,测不出来。

出处

RetryType:

pub enum RetryType {
    Decided(bool),
    ReusedOnly, // only retry when the error is from a reused connection
}

impl RetryType {
    pub fn decide_reuse(&mut self, reused: bool) {
        if matches!(self, RetryType::ReusedOnly) {
            *self = RetryType::Decided(reused);
        }
    }

    pub fn retry(&self) -> bool {
        match self {
            RetryType::Decided(b) => *b,
            RetryType::ReusedOnly => {
                panic!("Retry is not decided")
            }
        }
    }
}

v1 客户端读响应头时对端关闭(Ok(0))或遇到 OS 的 IO 错误,把错误标成 ReusedOnly;它的意思是「连接是从池里拿的就可以重试」,而只有 error_while_proxy 知道连接是不是复用的(参数 client_reused)。默认的 error_while_proxy 总会落定:非幂等方法 set_retry(false),否则 decide_reuse(client_reused)。覆盖它就替换了这段逻辑,trait 的文档注释写着覆盖者「are responsible for deciding when a retry is safe」,但类型上没有任何东西强制这一点。process_request 的循环拿到错误后第一件事就是 let retry = error.retry();,ReusedOnly 在这里 panic,lib.rs:77:17 就是上面那个 panic!。

0.8.0 也是同样的结构(默认实现里有 decide_reuse),覆盖时不落定同样 panic。tag 之后到 84f21ee,main 上的 pingora-error 没有改动。

修法

返回之前落定。最省事的是把默认实现整个搬过来,在它前面加自己的逻辑:

fn error_while_proxy(
  &self,
  peer: &HttpPeer,
  session: &mut Session,
  mut e: Box<Error>,
  _ctx: &mut (),
  client_reused: bool,
) -> Box<Error> {
  eprintln!("error_while_proxy {} reused={client_reused}: {e} retry={:?}", session.req_header().uri, e.retry);
  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
}

MODE=fix,同样三个请求:

/g1 200 exit=0
/g2 200 exit=0
/g3 200 exit=0
mode=fix
logging /g1 status=Some(200)
error_while_proxy /g2 reused=true: Upstream ConnectionClosed context: while reading response headers, bytes already read: 0 retry=ReusedOnly
[2026-10-29T06:44:07Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:19361, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, tries: 1, retry: true, GET /g2, Host: 127.0.0.1:19360
logging /g2 status=Some(200)
error_while_proxy /g3 reused=true: Upstream ConnectionClosed context: while reading response headers, bytes already read: 0 retry=ReusedOnly
[2026-10-29T06:44:07Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:19361, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, tries: 1, retry: true, GET /g3, Host: 127.0.0.1:19360
logging /g3 status=Some(200)

/g2 和 /g3 都落在复用的连接上,decide_reuse(true) 把它们变成可重试,换新连接后成功。只想打日志、不想改策略,这样写就和没覆盖时行为一致。不想要任何重试,e.set_retry(false) 也算落定。

fail_to_connect 不需要这一步:连接都没建成,错误不会是 ReusedOnly,默认就是 Decided(false)。

怎么确认没中招

  • 搜 fn error_while_proxy:每一条返回路径上都要有 decide_reuse( 或 set_retry(。
  • 用一个「只答第一个请求」的上游(上面的 flaky_upstream.py)对同一个代理连发两个请求:第二个拿到空回复、日志里有 Retry is not decided,就是中招了。
  • 线上日志搜 pingora-error-0.9.0/src/lib.rs:77:17。

小结

  • ReusedOnly 必须由 error_while_proxy 落定,RetryType::retry() 遇到它直接 panic(lib.rs:77:17)。
  • 覆盖 error_while_proxy 只打日志、原样返回,复用连接上的失败就会 panic,客户端空回复,没有 error 日志。
  • 修法:照搬默认实现(set_retry(false) / decide_reuse(client_reused)),在前面加自己的逻辑。

💬 关联讨论

发起讨论
加载中...