编程 重试不是越多越好:出站 HTTP 客户端的分类、full jitter 与每依赖预算

2026-10-17 21:31:29

重试不是越多越好:出站 HTTP 客户端的分类、full jitter 与每依赖预算

参考:

  • Matheus Palma, Designing outbound HTTP clients for production —
  • Stripe 速率限制文档 —

重试是必须的,但没有上限的重试等于同时对自己和合作方发起 DDoS。目标不是"更多重试",而是受控的、协作式的重试:尊重 deadline、尊重幂等约束、尊重服务端给出的信号。三者缺一,重试就会从容错机制变成故障放大器。

朴素重试的四种失败

  1. 重试了不可重试的语义。400 校验错误、401 认证失败,不改输入或不换凭证,重试一万次也不会成功,只是白占容量,还可能触发对方风控。
  2. 惊群。固定延迟会让所有客户端在同一时刻醒来,形成整齐的尖峰。抖动的作用就是把这种相关性打散。
  3. 忽略服务端意图。429/503 常常带 Retry-After,忽略它等于拒绝参与对方给出的稳定性契约。
  4. 没有全局上限。每个 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 时,先读一遍对方的限流文档,比在客户端里调参数有用得多。

复制全文 生成海报 接口对接 HTTP Go 重试 稳定性

推荐文章

程序员茄子在线接单