Rust in Everything
基于 pingora-error 0.9.0

错误、重试与故障转移

主线位置:请求主线里「连接器从连接池取连接或新建」和「双向转发」失败时的那条岔路。一次尝试失败后,process_request 的重试循环只问一个问题:这个错误的 retry 标记是不是真。标记由连接器、HTTP 客户端会话和两个钩子(fail_to_connect、error_while_proxy)一路改写;不重试了,fail_to_proxy 决定回给客户端什么。

flowchart TB
  try[proxy_to_upstream] --> peer[upstream_peer]
  peer -->|Err| out
  peer --> conn{取连接}
  conn -->|失败| ftc["fail_to_connect"]
  conn -->|成功| dx[双向转发]
  dx -->|出错| ewp["error_while_proxy"]
  dx -->|成功| done([finish])
  ftc --> chk{"retry 且 次数未满"}
  ewp --> chk
  chk -->|是| try
  chk -->|否| out[fail_to_proxy]
  out --> done

pingora-error 只有两个文件、675 行(lib.rs 加 immut_str.rs),没有依赖。实验在 exp-pingora/proxy-lab 的 retry 程序(M2 Max、macOS 27、rustc 1.99.0,release,tokio 1.53.2),0.8.0 的对照在 exp-pingora/proxy-08(pingora 0.8.0,tokio 1.52.3,debug)。

Error:类型、来源、重试、原因、上下文

Error 有五个公开字段:

pub struct Error {
    /// the type of error
    pub etype: ErrorType,
    /// the source of error: from upstream, downstream or internal
    pub esource: ErrorSource,
    /// if the error is retry-able
    pub retry: RetryType,
    /// chain to the cause of this error
    pub cause: Option<Box<dyn ErrorTrait + Send + Sync>>,
    /// an arbitrary string that explains the context when the error happens
    pub context: Option<ImmutStr>,
}

几乎所有 pingora 的函数都返回 pingora_error::Result<T>,也就是 Result<T, Box<Error>>。三个维度各管一件事:

  • ErrorType 是「出了什么事」,分组排列:连接(ConnectTimedout、ConnectRefused、TLSHandshakeFailure……)、协议(InvalidHTTPHeader、H1Error、H2Error、H2Downgrade、InvalidH2)、已建立连接上的 IO(ReadError、WriteError、ReadTimedout、WriteTimedout、ConnectionClosed)、HTTPStatus(u16)、文件、InternalError、UnknownError,以及给用户扩展的 Custom(&'static str) 和 CustomCode(&'static str, u16)。
  • ErrorSource 是「谁的错」:Upstream、Downstream、Internal、Unset。同一个 ReadError,来自上游就回 502,来自下游就什么都不回(连接已经断了)。
  • RetryType 是「能不能重试」,见下一节。

cause 让错误可以串成链。新建错误的几种写法:Error::new_up(e) / new_down / new_in 带来源不带上下文;Error::explain(e, "...") 带上下文;Error::because(e, "...", cause) 把另一个错误包进来;e.more_context("...") 保留类型、来源和重试标记,把自己包一层。Result 上对应有 or_err、or_err_with、explain_err(OrErr)。

Display 打印整条链,而且跳过和上一层相同的来源、类型。下面实验里的这一行:

Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18599, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18599 cause: Connection refused (os error 61)

拆开是三层:外层 Upstream、ConnectRefused,上下文是连接器加的(带 peer 的 Display);中层是 l4/ext.rs 的 wrap_os_connect_error 建的,来源 Unset(打印为空串)、类型同外层(不打印),所以 cause: 后面是两个空格;最内层是 std::io::Error。

重试标记:三个值,一个会 panic

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")
            }
        }
    }
}

ReusedOnly 是「如果这个连接是从池里拿的,就可以重试」:池里的连接可能已经被对端悄悄关了,第一次用它失败不代表上游有问题。它必须在被读之前由 decide_reuse 落定,否则 retry() 直接 panic。重试循环里的 error.retry() 之所以安全,是因为所有能产生 ReusedOnly 的错误都在拿到连接之后,必然经过 error_while_proxy,而它的默认实现总会落定;自己覆盖 error_while_proxy 又没处理 ReusedOnly,就会在这里 panic。proxy-lab/src/bin/ewp_panic.rs 的 error_while_proxy 只打印、原样返回,上游是下面「复用连接上的失败」一节的 flaky_upstream.py,第二个 GET 落在复用的连接上:

error_while_proxy: Upstream ConnectionClosed context: while reading response headers, bytes already read: 0 retry=ReusedOnly

thread 'Pingora HTTP Proxy Service' (5580833) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-error-0.9.0/src/lib.rs:77:17:
Retry is not decided

panic 只结束这个请求所在的任务,curl 得到 (52) Empty reply from server,服务进程还活着。覆盖时至少要调一次 e.retry.decide_reuse(client_reused) 或 e.set_retry(...)。

新建的错误默认 Decided(false);用 because 包住另一个 Box<Error> 时,继承里面那个的标记(Error::create)。

谁会把它改成可重试

位置情形标记
TCP 连接失败(connectors/l4.rs)拒绝、超时、不可达不改,false
proxy_connect(经 CONNECT 代理)失败decide_reuse(false)
v1 客户端 read_response读响应头时对端关闭(Ok(0)),或真正的 OS IO 错误ReusedOnly;TLS 错误不改
v2 客户端 handle_read_header_errorHTTP_1_1_REQUIRED、上游发了无效 h2 帧、GOAWAY(NO_ERROR)、REFUSED_STREAMtrue
IO 错误ReusedOnly
proxy_to_upstreamH2Downgrade / InvalidH2 而 peer 不允许降到 h1false
fail_to_connect 默认原样返回不改
error_while_proxy 默认非幂等方法,或重试缓冲已截断false
其他decide_reuse(client_reused)

所以默认配置下,连接失败从不重试;docs/user_guide/failover.md 说要重试或故障转移,就得在 fail_to_connect 里 e.set_retry(true)。failover.md 还有一句保证:「When fail_to_connect() is called, pingora-proxy guarantees that nothing was sent upstream」,连接都没建成,所以这里重试任何方法都安全。

error_while_proxy 的默认实现:

        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

「重试缓冲已截断」指请求体超过了 64 KiB(BODY_BUF_LIMIT):重试时要把请求体重发一遍,缓冲里只有前 64 KiB,重发不了。

max_retries 是尝试次数

process_request 的循环是 while retries < self.max_retries { retries += 1; ... },max_retries 来自 ServerConf,默认 DEFAULT_MAX_RETRIES = 16,字段注释说它是「a fail-safe」。因为先比较再加一,它限制的是 proxy_to_upstream 的调用次数,也就是总共尝试几次,而不是第一次之外再重试几次。

retry 程序的 upstream_peer 永远给 127.0.0.1:18599(没有进程在听),按 MODE 环境变量切换:default 什么都不改;always 在 fail_to_connect 里总是 set_retry(true);max3、max0 同 always,另外把 ServerConf::max_retries 设成 3、0。每次 curl -s -i --max-time 3 http://127.0.0.1:18500/x。

default:

mode=default max_retries=16
upstream_peer #1 GET 127.0.0.1:18599
fail_to_connect #1: Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18599, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18599 cause: Connection refused (os error 61) retry=false
[2026-10-29T04:53:34Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18599, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18599 cause: Connection refused (os error 61), status: 502, tries: 1, retry: false, GET /x, Host: 127.0.0.1:18500
logging attempts=1 status=502 err=Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18599, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18599 cause: Connection refused (os error 61)

curl 收到的:

HTTP/1.1 502 Bad Gateway
Server: Pingora
Date: Thu, 29 Oct 2026 04:53:34 GMT
Content-Length: 0
Cache-Control: private, no-store
Connection: close

试了一次,502,空响应体,Connection: close。max3 是 3 次 upstream_peer、两条 warn、一条 error,最后同样的 502。

❗IMPORTANT
动脑筋:总是可重试,会试几次?

MODE=always:fail_to_connect 每次都 e.set_retry(true),max_retries 保持默认。上游端口一直没开。upstream_peer 会被调用几次?这些尝试之间隔多久?curl 最后收到什么?

max_retries: 0

把 max_retries 设成 0,循环一次都不进:

mode=max0 max_retries=0
logging attempts=0 status=0 err=None

curl 的退出码是 52(Empty reply from server)。upstream_peer 没被调用,proxy_error 停在初值 None,于是 fail_to_proxy 也没被调用,finish 拿到的是 reuse = false、error = None:logging 看到的是一个「没有错误、也没有响应」的请求,连接被关掉,日志里什么都没有。字面上「最多重试 0 次」应该是试一次不重试,实际是一次都不试。这像是一个上游问题,见文末。

复用连接上的失败:0.9.0 不再重试 POST

tools/flaky_upstream.py 对每个连接只回答第一个请求(keep-alive),读到第二个请求就直接关连接、什么都不回。这正是「池里的连接已经被上游关掉」最常见的样子。MODE=reuse 让 retry 程序把请求发给它,依次请求 GET /g1、GET /g2、POST /p1(三次独立的 curl,下游连接各不相同,上游连接在池里复用)。

pingora 0.9.0,curl 依次收到 200、200、502:

upstream_peer #1 GET 127.0.0.1:18502
connected_to_upstream #1 reused=false
logging attempts=1 status=200 err=None
upstream_peer #1 GET 127.0.0.1:18502
connected_to_upstream #1 reused=true
[2026-10-29T04:53:52Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:18502, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, tries: 1, retry: true, GET /g2, Host: 127.0.0.1:18500
upstream_peer #2 GET 127.0.0.1:18502
connected_to_upstream #2 reused=false
logging attempts=2 status=200 err=None
upstream_peer #1 POST 127.0.0.1:18502
connected_to_upstream #1 reused=true
[2026-10-29T04:53:52Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:18502, 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:18500
logging attempts=1 status=502 err=Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:18502, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0

上游那边:

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

GET /g2 在复用的连接上读到 ConnectionClosed(ReusedOnly),error_while_proxy 落定为可重试,第二次用新连接成功。POST /p1 同样的错误,但方法不是幂等的,直接 502。同一个程序在 pingora 0.8.0 上(proxy-08,只实现了 upstream_peer 和 logging),curl 依次收到 200、200、200:

upstream_peer #1 POST
[2026-10-29T04:55:29Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:18503, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, tries: 1, retry: true, POST /p1, Host: 127.0.0.1:18504
upstream_peer #2 POST
logging attempts=2 status=200

上游的日志最后多一行 conn#3 req#1 POST /p1 HTTP/1.1:这个 POST 被上游收到了两次。在这个实验里第一次被丢掉了,但上游如果处理完再关连接(比如处理到一半崩溃),0.8.0 就会让一个下单请求执行两次。

📝NOTE
冷知识:error_while_proxy 的默认策略在 0.9.0 才看 HTTP 方法

0.8.0 的默认实现只有一行有效逻辑:e.retry.decide_reuse(client_reused && !session.as_ref().retry_buffer_truncated()),注释「only reused client connections where retry buffer is not truncated」,不看方法。docs/user_guide/phase.md 一直写着「This phase may decide to retry a request if the connection was re-used and the HTTP method is idempotent」,failover.md 也说 POST「not safe to retry if the requests have already been sent」,但默认代码直到 6fa3835(「Update default error_while_proxy retry conditions」,没有 PR 编号,首次进入 0.9.0)才对上:非幂等方法或缓冲已截断时强制 set_retry(false),否则才 decide_reuse。同一个提交给 trait 文档加了一段,说覆盖这个钩子的实现「replace the default policy and are responsible for deciding when a retry is safe」,并在 lib.rs 加了一组测试。CHANGELOG 0.9.0 没有列出这条行为变化。

新策略的一个后果:v2 客户端标为 true 的那几种错误(REFUSED_STREAM、GOAWAY(NO_ERROR)、HTTP_1_1_REQUIRED),handle_read_header_error 上方的注释把它们列为「we will retry」的情形,REFUSED_STREAM 那条还引了 RFC 9113 §8.7「Any request that was sent on the reset stream can be safely retried」;但它们同样经过 error_while_proxy,POST 遇到它们也不再重试。这一点只读了源码,没有跑。需要的话,自己覆盖 error_while_proxy,按 e.etype() 区分。

回给客户端什么:fail_to_proxy

fail_to_proxy 的默认实现:

        let code = match e.etype() {
            HTTPStatus(code) => *code,
            _ => {
                match e.esource() {
                    ErrorSource::Upstream => 502,
                    ErrorSource::Downstream => {
                        match e.etype() {
                            WriteError | ReadError | ConnectionClosed => {
                                /* conn already dead */
                                0
                            }
                            _ => 400,
                        }
                    }
                    ErrorSource::Internal | ErrorSource::Unset => 500,
                }
            }
        };
        if code > 0 {
            session.respond_error(code).await.unwrap_or_else(|e| {
                error!("failed to send error response to downstream: {e}");
            });
        }
错误状态码
类型是 HTTPStatus(code)code,不论来源
来源 Upstream502
来源 Downstream,类型是读、写错误或连接关闭不回(0)
来源 Downstream,其他400
来源 Internal 或 Unset500

几个推论:

  • 用户钩子里 Error::explain(InternalError, "...") 返回的错误来源是 Unset,客户端收到 500;想回 403 就用 HTTPStatus(403),或者自己写响应再返回错误。
  • 上游超时(ReadTimedout,来源 Upstream)也是 502,不是 504。
  • 绑定源地址失败在连接器里被改成 InternalError(connectors/l4.rs 的 SocketError | BindError => Error::e_because(InternalError, ...)),来源后来被 into_up() 改成 Upstream,所以是 502。

respond_error 生成一个空体响应(上面 curl 看到的 Server: Pingora、Content-Length: 0、Cache-Control: private, no-store),并且无条件 set_keepalive(None),所以总是 Connection: close。如果响应头已经写出去了(不是 1xx),它什么也不做:这时候能做的只有断开连接,failover.md 说的「once the response header is already sent downstream, there is nothing the proxy can do」。返回的 FailToProxy 里 can_reuse_downstream 默认 false;注释说这个值目前只对选上游之前的错误生效,而且 Session 自己的 keep-alive 设置优先,respond_error 已经把它关了。

日志:每次重试一条 warn,最后一条 error

可重试的失败每次打一条 warn!("Fail to proxy: {}, tries: {}, retry: {}, {}"),可以用 0.9.0 新增的 suppress_proxy_warn_log(上下文是 ProxyWarnLogContext::UpstreamRetry)压掉;最终错误打一条 error!,retry 字段写死 false,可以用 suppress_error_log 压掉。两者末尾都是 request_summary 的输出,默认是方法、路径和 Host(上面日志里的 GET /x, Host: 127.0.0.1:18500)。suppress_proxy_warn_log 的文档注释提醒:压掉重试日志会丢掉唯一的逐次重试记录,应该在钩子里自己记指标。

故障转移

failover.md 的例子是标准写法:CTX 里记一个 tries,fail_to_connect 里加一并 set_retry(true),upstream_peer 按 tries 选另一个地址。它和 0.9.0 的 API 一致。两点补充:

  • 重试之间没有任何等待,always 的 16 次尝试都在同一秒内(日志时间戳都是 04:53:35)。要退避,只能在 async 的 upstream_peer 里 sleep,因为 fail_to_connect 是同步的,pingora-proxy/examples/backoff_retry.rs 就是这样做的。
  • pingora-load-balancing 的 LoadBalancer::select 本身不知道哪个后端刚失败过,要靠 CTX 或健康检查,见 LoadBalancer。

tower 的重试是一个包在服务外面的 Policy,每次重试克隆请求;pingora 的重试在 process_request 里,请求头一直在 Session 上,请求体靠 64 KiB 的重试缓冲,对照见 tower 卷的 Policy 与 Future。

动脑筋答案

mode=always max_retries=16
upstream_peer #1 GET 127.0.0.1:18599
fail_to_connect #1: Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18599, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18599 cause: Connection refused (os error 61) retry=false
[2026-10-29T04:53:35Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18599, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18599 cause: Connection refused (os error 61), tries: 1, retry: true, GET /x, Host: 127.0.0.1:18500
...
[2026-10-29T04:53:35Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18599, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18599 cause: Connection refused (os error 61), tries: 16, retry: true, GET /x, Host: 127.0.0.1:18500
[2026-10-29T04:53:35Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18599, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18599 cause: Connection refused (os error 61), status: 502, tries: 16, retry: false, GET /x, Host: 127.0.0.1:18500
logging attempts=16 status=502 err=Upstream ConnectRefused context: Fail to connect to addr: 127.0.0.1:18599, scheme: HTTP cause:  context: Fail to connect to 127.0.0.1:18599 cause: Connection refused (os error 61)
  • upstream_peer 被调用 16 次(grep -c upstream_peer 得 16),等于 max_retries 的默认值,不是 17。
  • 每次 fail_to_connect 收到的错误都是 retry=false:重试标记是每次新建的错误自带的,上一轮 set_retry(true) 不会留到下一轮。
  • 16 条 warn 里最后一条也写着 retry: true,它是在循环判断次数之前打的;紧接着循环因为次数用完退出,error 日志写 retry: false。
  • 所有时间戳都在同一秒,尝试之间没有等待。curl 最后收到的和 default 一样:空体 502,Connection: close。

小结

  • Error 有类型、来源、重试标记、原因链和上下文;Display 打印整条链,跳过与上一层相同的来源和类型。
  • RetryType 有 Decided(bool) 和 ReusedOnly;后者必须先 decide_reuse,否则 retry() panic。
  • 默认连接失败不重试,要在 fail_to_connect 里 set_retry(true);复用连接上读不到响应头是 ReusedOnly,error_while_proxy 在 0.9.0 起只对幂等方法、且请求体没超过 64 KiB 时放行。0.8.0 会重试 POST,实测上游收到两次。
  • max_retries(默认 16)是总尝试次数;设成 0 时一次都不试,客户端得到空回复,没有任何错误日志。
  • fail_to_proxy 按 HTTPStatus → 来源 → 类型选状态码:上游 502、下游连接已断不回、下游其他 400、内部 500;错误响应总是关掉 keep-alive。

💬 关联讨论

发起讨论
加载中...