错误、重试与故障转移
主线位置:请求主线里「连接器从连接池取连接或新建」和「双向转发」失败时的那条岔路。一次尝试失败后,
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_error | HTTP_1_1_REQUIRED、上游发了无效 h2 帧、GOAWAY(NO_ERROR)、REFUSED_STREAM | true |
| IO 错误 | ReusedOnly | |
proxy_to_upstream | H2Downgrade / InvalidH2 而 peer 不允许降到 h1 | false |
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。
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 就会让一个下单请求执行两次。
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,不论来源 |
来源 Upstream | 502 |
来源 Downstream,类型是读、写错误或连接关闭 | 不回(0) |
来源 Downstream,其他 | 400 |
来源 Internal 或 Unset | 500 |
几个推论:
- 用户钩子里
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。