重试不是越多越好:出站 HTTP 客户端的分类、full jitter 与每依赖预算
参考:
- Matheus Palma, Designing outbound HTTP clients for production —
- Stripe 速率限制文档 —
重试是必须的,但没有上限的重试等于同时对自己和合作方发起 DDoS。目标不是"更多重试",而是受控的、协作式的重试:尊重 deadline、尊重幂等约束、尊重服务端给出的信号。三者缺一,重试就会从容错机制变成故障放大器。
朴素重试的四种失败
- 重试了不可重试的语义。400 校验错误、401 认证失败,不改输入或不换凭证,重试一万次也不会成功,只是白占容量,还可能触发对方风控。
- 惊群。固定延迟会让所有客户端在同一时刻醒来,形成整齐的尖峰。抖动的作用就是把这种相关性打散。
- 忽略服务端意图。429/503 常常带
Retry-After,忽略它等于拒绝参与对方给出的稳定性契约。 - 没有全局上限。每个 goroutine、每个 worker、每个请求各自重试,一万并发就变成一万倍放大。
先分类,再决定要不要重试
策略函数的输入应该是四元组:方法 + 状态码 + 错误类型 + 已用预算。
| 状态 | 处理 |
|---|---|
| 2xx | 不重试 |
| 400 / 404 / 409 / 422 | 稳定客户端错误,不重试,修参数或改流程 |
| 401 / 403 | 不重试,刷新凭证或直接停止 |
| 429 | 重试,先遵守 Retry-After,再退避 |
| 500 / 502 / 503 / 504 | 有时可重试,优先 Retry-After |
| 103 / 3xx | 跟随重定向,通常不进重试循环 |
传输层错误是另一类:DNS 失败、连接重置、TLS 握手错误、ETIMEDOUT 属于瞬时或本地网络问题,只有在操作幂等、或服务端有幂等键时才重试。
方法维度上,GET/HEAD/OPTIONS 和定义良好的 PUT/DELETE 比任意 POST 安全;PATCH 语义不定,按 POST 处理。POST 的尖角在于:服务端已经接受、但客户端超时,这是一个模糊状态。优先用服务端幂等键,或者把操作设计成自然幂等(按业务唯一键 upsert)。两者都没有时,遇到模糊超时应当 fail closed,交给对账而不是赌一把。
退避:指数增长 + 抖动
指数增长到 cap,可以在故障持续期间减少对恢复期服务端的压力。抖动负责破相关性,两种常见形式:
- full jitter:在
[0, cappedExponential]之间均匀采样。期望等待更低,打散效果更强,代价是单次方差大。 - equal jitter:一半固定 + 一半随机。下界更紧,仍然能破惊群。
cap 和最大次数的取舍很直接:更长的 cap 和更多次数提高长故障下的成功率,但会拉长用户路径的尾延迟。同步用户调用要短预算、快速失败;后台任务可以接受更长的 cap。
Retry-After:当契约用,别当建议
Retry-After 有两种格式:HTTP-date 绝对时间,或 delay-seconds 非负秒数。同时出现 429/503 与 Retry-After 时:
- 按 RFC 解析;
- 算出剩余等待
waitMs = max(0, suggestedWait - alreadyElapsed); - 与退避策略合并时取
max(backoffJitter, retryAfterWait)——保证不早于服务端要求,但可以更晚。
有些栈里 503 + Retry-After: 0 的语义是"立即重试",这种情况仍要加一个小抖动,避免退化成 tight loop。
HTTP-date 还有个坑:本地与服务端时钟偏移会产生负等待。clamp 到 0 或一个小下界,必要时用响应里的 Date 头做参考时钟。
一份可直接照抄的 Go 实现
调用方在 fn 里做请求、分类、解析 Retry-After 并塞进 Result;重试循环只关心策略。
package retry
import (
"context"
"errors"
"fmt"
"math"
"math/rand"
"net/http"
"strconv"
"strings"
"time"
)
type Class int
const (
ClassSuccess Class = iota
ClassRetryable
ClassTerminal
)
type Policy struct {
MaxAttempts int // 单次逻辑操作的最大尝试次数
BaseDelay time.Duration // 指数退避基数
MaxDelay time.Duration // 单次等待上限
TotalBudget time.Duration // 整个逻辑操作(含所有等待)的时间上限
}
type Result struct {
Class Class
RetryAfter time.Duration // 已扣除已耗时;= p.MaxAttempts {
return attempt, fmt.Errorf("retry attempts exhausted after %d: %w", attempt, lastErr)
}
wait := nextWait(p, attempt, res.RetryAfter)
if dl, ok := budgetCtx.Deadline(); ok && time.Now().Add(wait).After(dl) {
return attempt, fmt.Errorf("retry would exceed total budget: %w", lastErr)
}
t := time.NewTimer(wait)
select {
case wait {
wait = retryAfter
}
if wait float64(cap) {
exp = float64(cap)
}
return time.Duration(rand.Int63n(int64(exp) + 1))
}
// ParseRetryAfter 解析 Retry-After,返回相对当前时刻的等待时长。
func ParseRetryAfter(h http.Header, now time.Time) (time.Duration, bool) {
v := strings.TrimSpace(h.Get("Retry-After"))
if v == "" {
return 0, false
}
if secs, err := strconv.ParseInt(v, 10, 64); err == nil {
if secs < 0 {
return 0, false
}
return time.Duration(secs) * time.Second, true
}
t, err := http.ParseTime(v)
if err != nil {
return 0, false
}
// 服务端 Date 头作为参考时钟,抵消本地时钟偏移
ref := now
if d, err := http.ParseTime(h.Get("Date")); err == nil {
ref = d
}
wait := t.Sub(ref)
if wait < 0 {
wait = 0
}
return wait, true
}
fn 里拿到响应后,如果要丢弃 body,记得先 drain 或者显式 cancel——有些服务端依赖 body 被消费完才能复用 keep-alive 连接。
预算与隔离:每个依赖都要有天花板
重试预算指每个逻辑操作或每个时间窗口的重试上限,而不是"每个请求各自算"。
- 同步用户结账步骤:最多 2 次重试,失败就把错误暴露出来,交给客服或异步对账。
- 后台 enrichment worker:60 秒内最多 5 次,并配合熔断。
并发隔离(bulkhead)同样重要:给每个下游依赖单独设并发上限,一个抽风的合作方就吃不光你的 socket 和 worker。出站连接池要配合超时,并让入站请求的取消信号一路传播下去。
高扇出场景尤其要小心:50 个并行任务各自重试 5 次,就是 250 倍放大。重试应该收在共享客户端或队列层,而不是散落在每个任务里。
观测
每次尝试至少记录:attempt 序号、结果分类(成功 / 可重试失败 / 终态失败)、本次延迟、Retry-After 是否存在以及最终选择的等待时长、传播给合作方的 correlation/trace id。
一个容易提前发现问题的指标是重试率 retries ÷ total attempts。它突增的时间点,往往早于合作方故障真正暴露出来。
几个反复踩的坑
- 错误响应不读 body,连接耗在池子里。
- 5xx 无上限无限重试。
- jitter 和
Retry-After重复叠加:选一条规则,写进文档。 - 高扇出 worker 里每请求各自重试,没有共享预算。
- HTTP-date 时钟偏移算出负等待,没有 clamp。
附:Stripe 的 429 细节
Stripe 的 429 会带 Stripe-Rate-Limited-Reason 头,取值为 global rate / endpoint rate / global concurrency / endpoint concurrency / resource specific。没有这些头的 429 不是限流,可能是锁超时(lock_timeout 错误码)。SDK 内置自动重试,max_network_retries=2,连锁超时的 429 一起重试。接入第三方 API 时,先读一遍对方的限流文档,比在客户端里调参数有用得多。