返回文章列表

一次请求到底该重试几次:从 ModelGate 讲 Timeout、Retry 与 Full Jitter

从 ModelGate 的上游模型调用出发,解释整体超时、单次尝试超时、可重试错误分类、指数退避与 Full Jitter,以及为什么重试本身也可能放大故障。

ModelGate 是我用 Go 实现的一个 LLM API 网关。

对于普通请求,它最终需要把一次:

POST /v1/chat/completions

转发给真正的大模型 Provider。

最简单的实现当然是:

response, err := provider.Chat(ctx, req)

调用成功就返回。

调用失败就报错。

但只要服务开始依赖网络上的其他服务,就会遇到一种很常见的情况:

一次请求失败,并不一定意味着再试一次也会失败。

例如:

Provider 暂时返回 503
网络连接刚好被重置
DNS 查询短暂超时
单次请求超过了预期时间
Provider 返回 429

这些问题可能只持续几十毫秒或几秒。

于是一个很自然的想法是:

失败了就重试。

但真正实现之后,我发现“加一个 retry”反而会带来更多问题:

哪些错误应该重试?

一次请求最多重试多少次?

每次尝试可以占用多久?

如果三次尝试每次都等 30 秒,一个请求是不是可能卡 90 秒?

为什么两次重试之间不能立即继续?

为什么指数退避之后还需要随机数?

客户端都已经断开了,后台还应该继续重试吗?

SSE 已经向客户端返回内容以后,还能不能重新请求一次模型?

这篇文章从 ModelGate 当前的 Timeout 和 Retry 实现出发,梳理这些问题之间的关系。

项目地址:theHerta27/ModelGate

重试首先不是“失败以后再调一次”

最直接的重试可能写成:

response, err := provider.Chat(ctx, req)
if err != nil {
    response, err = provider.Chat(ctx, req)
}

甚至:

for i := 0; i < 3; i++ {
    response, err = provider.Chat(ctx, req)
    if err == nil {
        break
    }
}

代码很简单。

但它其实默认了几个危险的前提:

所有错误都值得重试

每次尝试可以一直等

失败后应该立即重新请求

客户端取消不重要

重复调用上游没有额外成本

这些前提在真实服务中几乎都不成立。

所以 ModelGate 没有把 Retry 理解成一个单独的 for 循环,而是把它和:

Timeout
错误分类
Backoff
Context
Circuit Breaker

放在一起设计。

为什么需要两层 Timeout

ModelGate 默认配置中有两个容易混淆的时间:

REQUEST_TIMEOUT = 30s
UPSTREAM_ATTEMPT_TIMEOUT = 10s

第一个是:

整个逻辑请求的时间预算。

第二个是:

某一次上游尝试最多可以占用的时间。

这两个东西并不一样。

假设没有单次 Attempt Timeout,只有:

REQUEST_TIMEOUT = 30s

第一次请求 Provider 就卡住 29 秒。

即使系统配置:

最多尝试 3 次

实际上也已经没有多少时间留给第二次和第三次尝试。

反过来,如果只有:

每次 Attempt Timeout = 10s

却没有整体超时,那么理论上可能变成:

第 1 次请求 10s
    ↓
等待 Backoff
    ↓
第 2 次请求 10s
    ↓
等待 Backoff
    ↓
第 3 次请求 10s

总耗时还要再加上中间的退避时间。

如果以后把最大尝试次数继续调高,整个请求可能越来越长。

因此 ModelGate 使用的是:

Overall Timeout
    ↓
Attempt Timeout
    ↓
Attempt Timeout
    ↓
Attempt Timeout

每一次 Attempt 都活在 Overall Context 里面。

Overall Timeout 是最外层预算

非流式请求进入 Gateway 后,会先创建:

ctx, cancel := context.WithTimeout(
    ctx,
    g.requestTimeout,
)
defer cancel()

默认:

requestTimeout = 30s

这个 Context 会继续向下传递到:

Gateway
  ↓
ChatService
  ↓
Router
  ↓
Governed Provider
  ↓
Retry
  ↓
真实 Provider
  ↓
HTTP Request

因此它相当于告诉整条调用链:

这次逻辑请求最多就给你这么多时间。

到了 Retry 内部,每一次尝试还会再次派生:

attemptCtx, cancel := context.WithTimeout(
    ctx,
    p.options.AttemptTimeout,
)

默认:

AttemptTimeout = 10s

于是:

父 Context:最多 30 秒

Attempt 1:
最多 10 秒

Attempt 2:
最多 10 秒

Attempt 3:
最多 10 秒

但这里不能机械地理解成:

10 + 10 + 10 = 一定 30 秒

因为中间还有 Backoff。

而父 Context 到期以后,无论当前进行到第几次尝试,都应该停止。

更准确的关系是:

所有 Attempt
+
所有 Backoff
+
其他主链路耗时

共同受 Overall Timeout 限制

所以整体超时是最终预算。

单次超时只是预算内部的一道边界。

子 Context 不可能突破父 Context

假设请求已经执行了 25 秒。

现在开始第三次尝试:

AttemptTimeout = 10s

这并不意味着它还能完整运行到:

35 秒

因为它派生自一个只剩 5 秒的父 Context。

所以实际效果更接近:

单次真正可用时间
=
min(
    AttemptTimeout,
    父 Context 剩余时间
)

这也是 Go context 的一个很重要的性质:

子 Context 可以设置更严格的 Deadline,但不能突破父 Context 已经存在的 Deadline。

因此:

Overall = 30s
Attempt = 10s

不是两个互不相关的计时器。

它们是一层套一层的时间预算。

为什么不能所有错误都重试

有些错误重试一次可能恢复。

有些错误再试十次也还是一样。

比如 Provider 返回:

401 Unauthorized

可能意味着 API Key 错了。

马上再请求:

第 1 次:401
第 2 次:401
第 3 次:401

没有意义。

同样:

400 Bad Request

通常表示请求本身不合法。

请求内容不变,只是重复发送,结果也不会自动改变。

所以 Retry 首先要回答:

这个错误有没有暂时恢复的可能?

ModelGate 没有直接根据:

err != nil

决定重试。

而是先把 Provider 错误整理成结构化的:

type UpstreamError struct {
    Provider   string
    Operation  string
    Kind       ErrorKind
    StatusCode int
    Retryable  bool
    Cause      error
}

这样 Retry 层就不需要通过:

错误字符串里有没有 "503"

之类的方式猜测错误含义。

ModelGate 当前重试哪些错误

当前实现明确允许重试的主要情况包括:

HTTP 429
HTTP 503
context.DeadlineExceeded
明确的网络 / Transport 故障

例如 Provider 返回:

429 Too Many Requests

会被标记为:

Retryable: true

Provider 返回:

503 Service Unavailable

同样:

Retryable: true

另外:

context.DeadlineExceeded

也被认为可以重试。

网络层还会识别一些明确的异常,例如:

DNS timeout
dial failure
read/write network error
unexpected EOF

这些问题有可能是暂时性的。

所以可以尝试下一次调用。

哪些错误不会重试

当前 ModelGate 不会因为:

400
401
403

这类客户端或认证错误不断重试。

另外:

响应 JSON 无法解析
Provider 返回不完整响应
SSE Content-Type 不合法

这类:

Invalid Response

也不会因为 Retry 自动重新请求。

客户端主动取消:

context.Canceled

更不会重试。

这一点非常重要。

因为:

用户已经关闭页面

如果网关还在后台:

Attempt 1
失败
等待
Attempt 2
Attempt 3

不仅没有意义,还会继续消耗:

网络连接
goroutine
Provider 请求额度
Token

所以 ModelGate 的判断首先是:

if errors.Is(err, context.Canceled) {
    return false
}

取消不是“上游暂时失败”。

它的意思是:

这次逻辑请求本身已经不需要继续了。

为什么当前并不是所有 5xx 都重试

这里还有一个我觉得值得明确的边界。

很多 Retry 示例会直接写:

所有 5xx
→ Retry

但 ModelGate 当前没有这么做。

在 HTTP Status 的 Provider Adapter 中,目前只有:

429
503

被显式标记为 Retryable。

也就是说:

500
502
504

并不会仅仅因为属于 5xx,就自动进入当前的 Retry Policy。

这是一项比较保守的策略。

它至少保证:

重试集合是明确列出来的,而不是把所有服务端错误都默认当成暂时错误。

如果以后要扩大重试范围,也应该针对真实 Provider 的错误语义重新判断,而不是简单把条件改成:

status >= 500

因为:

“服务端错误”与“重复请求有恢复价值”并不是完全相同的概念。

Retry 也不是免费的

假设:

一次请求成功成本 = 1

如果 Provider 出故障,而所有网关都执行:

失败
→ 立即重试 3 次

原本:

1000 个请求

可能迅速变成:

3000 次上游调用

甚至更多。

所以重试实际上有一个很危险的特点:

系统越失败,Retry 产生的额外流量反而可能越多。

如果 Provider 本来只是因为负载过高返回:

503

网关立即发起更多请求:

503
↓
Retry
↓
503
↓
Retry

就可能进一步增加上游压力。

这种现象通常被称为:

Retry Storm

也就是重试风暴。

因此 Retry 的目标不是:

失败就多试几次,总会成功。

而应该是:

在有限预算内,对真正可能暂时恢复的错误进行有限次数的再次尝试。

为什么失败以后不能马上重试

假设 Provider 因为瞬时过载返回:

503

如果网关立即:

0ms 后重试

上游可能根本还没有恢复。

于是:

503
↓
立即 Retry
↓
503
↓
立即 Retry
↓
503

三次尝试几乎挤在同一瞬间。

既没有给 Provider 恢复时间,又额外制造流量。

所以重试之间需要等待。

最简单的方式是固定:

每次等 100ms

但固定间隔也有问题。

如果现在有 1000 个网关实例同时收到错误:

t = 0
所有请求同时失败

然后大家都:

等待 100ms

那么:

t = 100ms
1000 个实例再次同时请求

流量还是集中在一个时间点。

只是整体往后平移了 100ms。

指数退避解决什么问题

ModelGate 使用指数退避。

默认:

BaseBackoff = 100ms
MaxBackoff  = 2s

随着连续失败次数增加,下一次等待时间的上限逐步增加。

如果暂时忽略随机化,大致是:

第 1 次失败
→ 100ms

第 2 次失败
→ 200ms

第 3 次失败
→ 400ms

第 4 次失败
→ 800ms

第 5 次失败
→ 1.6s

继续失败
→ 最多 2s

源码中的核心逻辑就是:

delay := p.options.BaseBackoff

for step := 1; step < failedAttempt; step++ {
    delay *= 2
}

if delay > p.options.MaxBackoff {
    return p.options.MaxBackoff
}

也就是:

等待上限不断增长

让系统在连续失败时逐渐减少请求频率。

这其实是在表达:

如果刚刚失败了一次,也许只是瞬时抖动。

如果已经连续失败很多次,就越来越没有必要急着马上再试。

但指数退避仍然会同步

假设:

1000 个实例

在几乎同一时间失败。

如果大家都严格按照:

100ms
200ms
400ms
800ms

等待。

那么大家还是会:

一起失败
一起睡 100ms
一起醒来

一起失败
一起睡 200ms
一起醒来

于是流量会形成一波一波的尖峰:

请求量

│       █████
│
│            █████
│
│                  █████
│
└──────────────────────── 时间

这就是为什么指数退避以后还需要:

Jitter。

Full Jitter 到底在随机什么

ModelGate 当前默认使用:

fullJitter

源码非常短:

func fullJitter(maximum time.Duration) time.Duration {
    if maximum <= 0 {
        return 0
    }

    return time.Duration(
        rand.Int64N(int64(maximum) + 1),
    )
}

假设第一次失败对应的退避上限是:

100ms

并不是所有请求都等:

100ms

而是在:

0 ~ 100ms

之间随机选择。

例如:

实例 A → 13ms
实例 B → 47ms
实例 C → 81ms
实例 D → 6ms

第二次失败时,上限变成:

200ms

每个请求就在:

0 ~ 200ms

之间重新随机。

所以:

指数退避

决定的是:

随着失败次数增加,随机等待的最大范围有多大。

而:

Full Jitter

决定的是:

每个请求在这个范围里实际睡多久。

组合起来就是:

第 n 次失败
    ↓
计算 exponential backoff ceiling
    ↓
限制到 MaxBackoff
    ↓
从 0 ~ ceiling 中随机取一个 delay

为什么叫 Full Jitter

如果退避上限是:

400ms

Full Jitter 直接在完整区间:

0 ~ 400ms

随机。

所以可以粗略理解成:

delay = random(0, backoff)

它的主要作用不是让某一个请求:

更快成功

而是让大量请求:

不要在同一个时刻醒来

例如原本:

100ms
100ms
100ms
100ms
100ms

变成:

7ms
28ms
51ms
74ms
96ms

流量就被摊开了。

所以 Jitter 真正解决的是:

多个客户端或服务实例之间的同步重试问题。

为什么 MaxBackoff 还需要上限

指数增长如果没有上限:

100ms
200ms
400ms
800ms
1.6s
3.2s
6.4s
12.8s
...

等待时间会越来越大。

但 ModelGate 的整个请求本身还有:

REQUEST_TIMEOUT = 30s

如果 Retry 在某次失败后决定:

睡 20 秒

剩余预算很可能已经没有实际价值。

因此:

RETRY_MAX_BACKOFF = 2s

给退避上限再加了一层边界。

这里其实出现了三层限制:

MaxAttempts
→ 最多尝试多少次

MaxBackoff
→ 两次尝试之间最多等多久

Overall Timeout
→ 整个逻辑请求最多活多久

它们分别约束:

次数
间隔
总时间

默认最多三次尝试,不等于“重试三次”

ModelGate 默认:

UPSTREAM_MAX_ATTEMPTS = 3

这里的意思是:

总尝试次数 = 3

也就是:

第 1 次正常调用
+
最多 2 次 Retry

而不是:

第一次调用
+
额外重试 3 次
=
4 次

这是配置 Retry 时很容易混淆的一个细节。

源码循环是:

for attempt := 1;
    attempt <= p.options.MaxAttempts;
    attempt++ {

因此:

MaxAttempts = 3

就是最多三次上游调用。

Backoff 本身也必须能取消

假设:

Attempt 1
→ 503

Retry 算出:

delay = 800ms

于是开始等待。

就在这 800ms 期间:

客户端关闭了连接

如果代码只是:

time.Sleep(delay)

那这段 goroutine 仍然会完整睡完。

醒来以后可能才发现:

请求已经没人需要了

ModelGate 没有直接用 time.Sleep。

而是:

timer := time.NewTimer(delay)

select {
case <-ctx.Done():
    // 请求已经取消
    return ctx.Err()

case <-timer.C:
    // 等待完成,进入下一次尝试
}

所以 Backoff 本身也是:

可取消的。

如果父 Context 被取消:

客户端断开
Overall Timeout 到期
上层主动取消

等待会立即结束。

这和前面 SSE 文章里提到的 Context 传播其实是同一条原则:

请求生命周期已经结束,就不应该继续占用下游资源。

单次 Attempt 超时以后为什么可以继续

Retry 的测试中有一个很直接的场景。

第一次 operation:

<-ctx.Done()
return ctx.Err()

也就是故意一直等待到:

AttemptTimeout

触发。

第一次得到:

context.DeadlineExceeded

因为 Deadline 被归类为 Retryable:

Attempt 1
→ timeout
→ Retry

第二次 operation 正常成功。

最终整个逻辑请求仍然成功。

这个例子很好地说明了两层 Timeout 的区别:

Attempt Timeout

只表示:

这一次尝试不能继续等了。

并不一定表示:

整个逻辑请求必须失败

只有父级:

Overall Timeout

也到期,才代表:

整个请求预算耗尽。

父 Context 到期以后不能继续 Retry

Retry 内部每轮开始都会检查:

if err := ctx.Err(); err != nil {
    return nil, err
}

一次 Attempt 失败以后也会再次确认:

if parentErr := ctx.Err(); parentErr != nil {
    return nil, parentErr
}

所以假设:

Attempt 1 超时

但此时:

Overall Context 也已经 DeadlineExceeded

就不会再进入:

Attempt 2

这避免了一种错误逻辑:

单次 timeout
→ 看到 retryable
→ 不管整个请求还剩多少时间
→ 继续重试

Retry Policy 必须服从更外层的请求生命周期。

为什么流式请求的 Retry 更麻烦

非流式请求很容易理解:

请求 Provider
    ↓
完整响应 / error

如果在拿到完整响应之前失败,可以根据错误决定是否再次请求。

但 SSE 不一样。

它的生命周期是:

建立 HTTP 请求
    ↓
拿到 SSE Response
    ↓
持续 Recv
    ↓
不断向客户端转发
    ↓
[DONE]

所以必须区分两个阶段。

第一阶段:流还没有建立

例如:

连接 Provider 失败
Provider 返回 503
Provider 返回 429

这时候客户端还没有收到模型输出。

ModelGate 可以执行 Retry。

第二阶段:流已经建立

假设已经收到:

data: {"content":"你好"}

然后又收到:

data: {"content":",我是"}

此时 Provider 连接突然中断。

如果网关重新发起一次完整模型请求:

Prompt
↓
从头生成

新生成的结果未必和第一次完全相同。

客户端可能得到:

第一次:
你好,我是

第二次重新生成:
你好!今天...

两条流根本无法安全拼接。

而且重新请求还可能再次产生 Token 费用。

因此 ModelGate 的 Retry 只负责:

建立流之前的 Attempt。

流成功建立以后,它的生命周期会交给 managedStream。

后续:

EOF
error
Close

负责结束流、释放并发 permit、更新熔断器状态和取消 Attempt Context。

但不会:

中途断流
→ 自动重新请求整个 LLM

这个边界和上一篇 SSE 文章里的:

第一帧之后不能重新返回 JSON 错误

本质上很相似。

都是因为:

系统一旦已经向外产生了不可撤销的结果,后续可选择的恢复手段就变少了。

为什么流成功建立后不能立刻 Cancel Attempt Context

非流式 Retry 成功时:

cancel()
return nil

很自然。

因为完整响应已经拿到了。

这次 Attempt 的生命周期已经结束。

但流式调用不同。

ChatStream 返回:

stream

只表示:

流已经建立。

真正的网络读取还在后面继续。

如果 Retry 函数一返回就:

cancel()

那么这个 Context 会立刻被取消。

仍然依赖它读取 SSE Body 的 Stream 也会一起结束。

所以 ModelGate 单独实现了:

DoRetained

成功建立流以后,不立刻调用:

CancelFunc

而是把 CancelFunc 交给 Stream 的 owner。

最终在:

EOF
error
Close

时统一调用。

这其实不是 Retry 算法本身的问题。

而是:

Context 的所有权和资源生命周期问题。

谁创建了一个会继续活下去的资源,谁就必须明确:

什么时候结束它
谁负责 cancel
谁负责 close

Retry 为什么放在 Provider 内,而不是 Router 外面

ModelGate 的调用大致是:

Router
  ↓
选择一个健康 Provider
  ↓
Governed Provider
  ↓
Semaphore
  ↓
Circuit Breaker
  ↓
Retry
  ↓
真实 Provider

也就是说:

Router 先选择 Provider,然后 Retry 在这个 Provider 内部执行。

如果 primary 被选中:

Attempt 1 → primary
Attempt 2 → primary
Attempt 3 → primary

当前实现不会因为:

primary 返回了一次 503

就立刻切换到另一个 Provider 重新执行。

Router 只有在发现:

Circuit Breaker 已经 OPEN

时,才会排除这个目标并继续选择其他健康 Provider。

这样 Router 和 Retry 的职责比较清晰:

Router
→ 选择哪个 Provider

Retry
→ 对已经选中的 Provider 是否再次尝试

而不是每次失败都重新做一次全局路由。

Retry 和 Circuit Breaker 为什么不能混为一谈

两者经常同时出现,但解决的问题并不相同。

Retry 问的是:

这一次请求失败以后,要不要再试?

Circuit Breaker 问的是:

这个 Provider 最近是不是已经差到不值得继续尝试?

所以 Retry 关注的是:

单个逻辑请求

Circuit Breaker 关注的是:

一段时间内大量请求形成的故障状态

ModelGate 里还有一个比较重要的处理:

熔断器记录的是 Retry 结束以后的最终逻辑结果,而不是每一次内部 Attempt 都直接记作一次失败。

例如:

Attempt 1 → 503
Attempt 2 → 503
Attempt 3 → 成功

对于这一个逻辑请求:

最终结果 = success

而不是让熔断器认为:

发生了 2 次失败 + 1 次成功

否则一个配置了三次 Retry 的请求,就会比没有 Retry 的请求对熔断窗口产生三倍影响。

Retry 是内部恢复过程。

熔断器看到的应该是:

这个逻辑请求最终有没有被恢复。

下一篇专门讲熔断器时,可以继续展开这个关系。

Retry 可能产生重复调用

还有一个不能因为实现了 Retry 就忽略的问题:

网关认为第一次失败,并不代表 Provider 一定没有处理第一次请求。

例如:

ModelGate
    ↓
请求已经到达 Provider
    ↓
Provider 完成生成
    ↓
返回响应时网络连接中断
    ↓
ModelGate 只看到 network error

此时 ModelGate 再 Retry:

第二次请求 Provider

上游实际上可能执行了两次模型调用。

这可能带来:

重复 Token 消耗
重复计费
不同的生成结果

所以 Retry 并不能提供:

exactly once

语义。

它只是:

在某些暂时性失败下,用重复尝试提高获得成功响应的机会。

这也是为什么“是否值得重试”不仅是技术问题,还和上游操作是否安全重复有关。

对于一个真正有明显副作用的 API:

扣款
发消息
创建订单

不能看到 timeout 就无脑重试。

还必须配合:

幂等键
唯一业务 ID
服务端去重

才能控制重复执行。

Idempotency-Key 也不能阻止 Gateway 内部的上游 Retry

ModelGate 自己支持非流式请求:

Idempotency-Key

它主要处理的是:

客户端
    ↓
重复调用 ModelGate

例如客户端第一次请求已经完成,但响应丢失。

同一个 key 再次请求时,可以重放已经保存的结果。

但一次逻辑请求内部:

ModelGate
    ↓
Provider

发生 Retry,是另一层问题。

如果 Provider 本身不支持对应的幂等语义,那么:

Attempt 1
Attempt 2

仍然可能是真正的两次上游执行。

所以:

客户端 → Gateway 的幂等

和:

Gateway → Provider 的 Retry

不能混成一件事。

它们解决的是不同边界上的重复调用。

为什么不能只看“最终请求成功率”

ModelGate 为每次上游 Attempt 都记录指标。

OnAttempt 会进入:

telemetry.ObserveUpstreamAttempt(
    providerName,
    attempt,
)

Attempt 最终被区分成:

success
retryable_error
error

为什么要记录 Attempt,而不是只记录:

最终请求成功

假设现在:

100 个用户请求
最终 100 个都成功

表面看:

成功率 = 100%

但实际可能是:

80 个一次成功
20 个全部先 503,再 Retry 成功

这说明 Provider 已经开始出现异常。

只看最终结果会把这部分问题隐藏掉。

如果 Retry 越来越频繁:

用户暂时还没感知失败

但:

延迟
上游请求数
资源消耗

都可能已经开始上升。

所以 Retry 是一种:

恢复机制。

恢复机制本身被频繁触发,也是一种需要观察的异常信号。

怎样测试 Retry 的关键边界

ModelGate 的 Retry 测试没有只验证:

失败以后最终成功

而是分别检查几个关键行为。

Retryable Error 会继续尝试

模拟:

Attempt 1 → retryable error
Attempt 2 → retryable error
Attempt 3 → success

最终要求:

attempts = 3
result = success

Non-Retryable Error 只执行一次

模拟永久错误:

Attempt 1 → permanent error

即使:

MaxAttempts = 3

也要求:

attempts = 1

因为最大次数不是:

必须执行次数。

它只是:

最多允许次数。

Attempt Timeout 可以触发下一次尝试

第一次故意一直阻塞到:

context.DeadlineExceeded

第二次成功。

验证:

单次超时
≠
整个请求必然失败

Parent Cancellation 必须停止 Backoff

第一次失败以后进入一个很长的等待。

随后主动:

cancel()

测试要求:

不能进入第二次 Attempt

并立即返回:

context.Canceled

流式成功必须保留 Attempt Context

DoRetained 成功建立 Stream 后:

Context 仍然有效

只有调用返回的:

CancelFunc

以后,Context 才真正取消。

这个测试验证的是流式请求的生命周期,而不只是 Retry 次数。

面试时我会怎样回答

如果面试官问:

你这个 LLM 网关的重试是怎么做的?

我会回答:

ModelGate 给一次逻辑请求设置 Overall Timeout,默认 30 秒;每次上游 Attempt 再从父 Context 派生一个更短的 10 秒 Timeout,最多总共尝试 3 次。这样某一次调用卡住不会直接耗尽整个请求预算,但所有 Retry 和 Backoff 最终还是受父 Context 限制。

Retry 不是所有错误都执行。当前只重试 429、503、DeadlineExceeded 和明确的 Transport Failure,像 400、401、403、非法响应和客户端取消都不会重试。

两次尝试之间使用指数退避,默认从 100ms 开始、最大 2s,再用 Full Jitter 从 0 到当前退避上限随机选择实际等待时间,主要是避免多个网关实例同时失败后又同时重试形成 Retry Storm。

Backoff 等待本身也监听父 Context,所以客户端断开或者 Overall Timeout 到期时会立即停止。流式请求只允许在流建立之前 Retry,一旦 SSE 流已经开始,后续中断不会自动重新发起整个模型请求。

如果继续问:

为什么既要 Attempt Timeout,又要 Overall Timeout?

我会回答:

Attempt Timeout 限制单次上游调用,给后续恢复留下机会;Overall Timeout 限制整个逻辑请求,包括多次 Attempt 和 Backoff。只有 Attempt Timeout,没有总预算,请求时间可能随着重试次数不断增长;只有 Overall Timeout,一次慢调用又可能把全部预算耗光。

如果继续问:

为什么要 Full Jitter?

我会回答:

只有指数退避时,大量实例如果同时失败,仍然可能按照相同的 100ms、200ms、400ms 节奏一起醒来。Full Jitter 会在 0 到当前退避上限之间随机取等待时间,把重试流量摊开,降低同步重试形成的流量尖峰。

我从这个问题中理解到的事情

最开始看到 Retry 时,我更容易把它理解成:

失败
→ 再试一次

但真正把它放进网关以后,我觉得 Retry 本身其实就是一种资源消耗行为。

一次正常调用:

1 次上游请求

加上 Retry 以后可能变成:

2 次
3 次
更多

所以系统不能只问:

能不能重试?

还要问:

为什么失败?
这类失败重试有用吗?
单次允许等多久?
整个请求还有多少预算?
下一次应该什么时候再发?
客户端还需要这个结果吗?
重复执行上游会不会有副作用?
系统已经向客户端输出结果了吗?

ModelGate 当前把这套关系组织成:

Overall Timeout
→ 限制整个逻辑请求生命周期

Attempt Timeout
→ 限制单次 Provider 调用

Error Classification
→ 决定哪些错误值得再次尝试

MaxAttempts
→ 限制调用次数

Exponential Backoff
→ 连续失败时逐渐降低重试频率

Full Jitter
→ 打散不同实例的同步重试

Context
→ 让取消信号贯穿 Attempt 和 Backoff

Stream Boundary
→ 流建立后不再重新执行整个请求

其中我觉得最重要的一点是:

Retry 不是为了掩盖失败,而是在明确的时间、次数和错误边界内,为暂时性故障提供一次恢复机会。

如果没有这些边界,Retry 很容易从:

提高可用性的机制

变成:

故障发生时额外制造流量的机制

而 Timeout、Backoff、Jitter 和错误分类真正做的事情,就是给这种“再次尝试”划出边界。

延伸阅读

  • Go Documentation:Package context
  • Go Documentation:net/http
  • AWS Architecture Blog:Exponential Backoff and Jitter
  • Google Cloud Architecture Framework:Retry and exponential backoff