一次请求到底该重试几次:从 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