http.Client 不支持重试,手动用 for + time.Sleep 封装易引发 context 失效、重试风暴、goroutine 泄漏及 body 重放失败。

Go 语言标准库的 http.Client 完全不提供重试能力,所有重试逻辑必须手动封装;用裸 for + time.Sleep 写法看似简单,但极易导致 context 取消失效、重试风暴、goroutine 泄漏和 body 重放失败。
为什么不能直接在 Do 外套 for 循环
很多人写类似这样的代码:
for i := 0; i < 3; i++ {
resp, err := client.Do(req)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
time.Sleep(time.Second)
}问题不止是“丑”,而是几个硬伤:
-
req.Body是io.ReadCloser,第一次Do后已读空或关闭,第二次调用会报http: Request.Body is nil - 没调
req.Clone(ctx),新请求仍用旧 context,ctx.Done()无法中断后续重试 -
time.Sleep阻塞 goroutine,取消信号进不来,超时后还在等、还在发 - 固定 1s 间隔 → 多个请求同时失败 → 同一时刻发起重试 → 下游被压垮(重试风暴)
- 没区分错误类型:把
401 Unauthorized和net.OpError{Err: "i/o timeout"}一样对待,纯属添乱
用 backoff.Retry 封装一次可重试的 Do 调用
推荐使用 github.com/cenkalti/backoff/v4,它把抖动(jitter)、上下文传播、状态隔离、最大耗时都收口了。关键不是“用不用库”,而是它强制你面对真实约束:
立即学习“go语言免费学习笔记(深入)”;
- 操作函数必须是
func() error,天然要求你提前绑定参数、隔离状态 - 必须用
backoff.WithContext(b, ctx)包一层,否则重试循环本身不响应 cancel - 每次重试前要
req.Clone(ctx),否则Body不可重放 - 退避实例不能复用:如果多个请求共用一个
backoff.ExponentialBackOff,NextBackOff()返回的间隔会错乱;要么每次新建,要么调b.Reset() - 默认
MaxInterval = 128 * time.Second,对 API 调用太长,建议显式设为1 * time.Second
示例片段:
bo := &backoff.ExponentialBackOff{
InitialInterval: 100 * time.Millisecond,
MaxInterval: 1 * time.Second,
MaxElapsedTime: 5 * time.Second,
}
bo = backoff.WithJitter(bo)
<p>err := backoff.Retry(func() error {
r, err := client.Do(req.Clone(ctx))
if err != nil {
return err // 网络层错误,交给 backoff 判断是否重试
}
defer r.Body.Close()
if r.StatusCode >= 500 || r.StatusCode == 429 || r.StatusCode == 408 {
io.Copy(io.Discard, r.Body) // 清空 body,否则连接无法复用
return fmt.Errorf("status %d", r.StatusCode) // 触发重试
}
return nil // 成功,退出重试
}, backoff.WithContext(bo, ctx))POST/PUT 请求怎么安全重试
非幂等请求重试最危险,也最容易出错。根本矛盾在于:*http.Request.Body 是一次性读取流,不可 rewind。
- 别试图用
bytes.Buffer或strings.Reader“缓存”原始 body 后反复赋值——如果原始 body 是文件句柄或管道,这么干会丢数据或 panic - 正确做法:原始 payload 存为
[]byte,每次重试用bytes.NewReader(payload)新建Body - 如果 payload 来自
io.Reader(如文件上传),必须先完整读入内存再重试,否则无法保证一致性 - 服务端必须支持幂等性(如通过
Idempotency-Key头),否则重试等于重复下单
context.WithTimeout 套 retry 时 timeout 到底算谁的
很多人以为这样写就控制住了总耗时:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := backoff.Retry(op, backoff.WithContext(bo, ctx))
其实无效——如果 op 第一次执行卡住 4 秒,第二次又卡 4 秒,backoff.Retry 本身不会主动 abort,除非你在 op 内部也检查 ctx.Err()。
- 必须在每次
Do前用新 context:比如req = req.Clone(http.WithContext(req.Context(), subCtx)) - 外层
WithTimeout控制的是整个重试流程生命周期,但每次 HTTP 请求仍需自己的超时控制(如http.Client.Timeout或更细粒度的context.WithTimeout) - 真正生效的 timeout 是两层叠加:单次请求超时 + 整体重试超时,缺一不可
最易被忽略的一点:body 读取也要受 context 约束。别只管 Do,忘了 io.ReadAll(resp.Body) 也可能卡住。用 io.CopyN 或带 timeout 的 http.MaxBytesReader 更稳妥。


















