Go 语言如何实现对 HTTP 请求的自动重试机制

先说一个核心事实:Go 语言标准库的 http.Client 完全不提供自动重试。无论你如何精心配置 Timeout、Transport 还是 CheckRedirect,一旦遭遇网络超时、502 网关错误或 DNS 解析失败这类临时性问题,它都会直接返回错误,绝不会帮你多试一次。
为什么不能只靠 http.Client.Timeout 或 Transport 参数
这里有个常见的认知偏差。http.Client.Timeout 仅仅控制单次请求的总耗时,超时后它只会返回 context.DeadlineExceeded 或 net/https: request canceled,然后就此打住。至于 Transport 里的 MaxIdleConnsPerHost 等参数,它们只管连接复用,跟失败重发没有半点关系。误以为这些配置能“触发重试”,是线上服务故障的一个高频原因。
那么,哪些错误值得重试,哪些又该直接放弃呢?
- 应该重试的错误:
context.DeadlineExceeded(上层超时)、net.OpError(包含"i/o timeout"、"connection refused"等网络层错误)、HTTP 状态码500/502/503/504(服务器内部错误)、429(限流)、408(请求超时)。 - 绝对不该重试的错误:
400、401、403、404、405—— 这些通常是客户端语义错误(比如参数不对、没权限),重试只会放大问题,毫无意义。 - 一个关键技巧:别再用
strings.Contains(err.Error(), "timeout")这种字符串匹配来判断错误类型了,既不准确也不优雅。正确的做法是使用errors.As(err, &netErr)或errors.Is(err, context.DeadlineExceeded)进行类型断言。
用 backoff.Retry 封装一次可重试的 http.Do
手动写 for 循环加 time.Sleep 来实现重试?很容易漏掉 context 取消信号的传播、忘记加入随机抖动,或者导致重试状态混乱。更推荐的做法是使用成熟的退避库,比如 github.com/cenkalti/backoff/v4(v4 版本支持 Go modules,并且修复了 context 取消传播的问题)。
具体怎么用?核心是把 http.Do 包装成一个闭包函数:
- 将你的请求逻辑包进一个无参函数里,让它返回
error。backoff.Retry会在 error 非 nil 时,按照你设定的策略自动重试。 - 必须使用
backoff.WithContext(b, ctx)将上下文与退避器绑定,否则当上游通过req.Context().Done()取消请求时,重试循环无法被中断。 - 每次重试前,务必调用
req.Clone(ctx)克隆一个新的请求。因为req.Body在第一次读取后就会被关闭或清空,不克隆的话,第二次Do就会报https: Request.Body is nil的错误。 - 注意配置:
backoff.NewExponentialBackOff()默认的MaxInterval是 128 秒,这对于大多数 API 调用来说太长了。通常需要显式设置为更合理的值,比如b.MaxInterval = 1 * time.Second。
err := backoff.Retry(func() error {
r, err := client.Do(req.Clone(ctx))
if err != nil {
return err // 网络层错误,可重试
}
defer r.Body.Close()
if r.StatusCode >= 500 && r.StatusCode < 600 {
return fmt.Errorf("server error: %d", r.StatusCode) // 5xx 视为可重试
}
if r.StatusCode == 429 || r.StatusCode == 408 {
return fmt.Errorf("rate limited or timeout: %d", r.StatusCode)
}
return nil // 成功,退出重试
}, backoff.WithContext(b, ctx))
带 body 的 POST/PUT 请求怎么安全重试
这才是重试机制里真正的“坑王”。*http.Request.Body 是一个 io.ReadCloser,它的特性是:读完即关闭,无法重复读取。如果直接重试,后续请求的 body 就会是空的,服务端根本收不到数据。
怎么解决?
- 推荐方案:在首次创建请求前,就把原始的 payload 数据保存为
[]byte。然后,在每次重试时,使用bytes.NewReader(payload)创建一个新的 reader 来作为 body。 - 避免踩坑:不要直接用
strings.NewReader("...")传递字符串,遇到中文或特殊字符时,编码可能出问题。 - 性能考量:如果 payload 很大(比如超过 1MB),反复拷贝字节切片会消耗内存。这时可以考虑使用支持
Seek的bytes.Reader,或者自定义一个可重放的io.ReadCloser。 - 最后一条,也是最重要的一条:对于非幂等请求(比如创建订单、支付),在实施重试前,务必与服务端约定好幂等机制,例如通过
Idempotency-Key这样的请求头来确保请求不会重复执行。
封装成 RetryClient 时最易忽略的细节
把重试逻辑封装成一个独立的 RetryClient 是个好主意,但结构体字段的设计比方法实现更重要。暴露太多内部细节,很容易导致误用和语义污染。
- 封装原则:不要把
*http.Client直接嵌入结构体并导出。应该只对外暴露一个Do(*http.Request) (*http.Response, error)方法,隐藏内部实现。 - 重试次数:
MaxRetries建议默认设为 3 次,最大不要超过 5 次。设置太大会掩盖真实的服务器故障,让问题更难被发现。 - 状态隔离:每个请求必须使用独立的
backoff.BackOff实例(记得调用b.Reset())。绝对不能让多个 goroutine 共享同一个实例,否则退避间隔会完全错乱。 - 日志记录:重试日志建议使用
backoff.RetryNotify,在它的通知回调函数里记录。避免在重试函数内部混入打日志这类副作用逻辑,保持函数纯粹。
说到底,实现重试机制的难点,往往不在于“怎么写循环”,而在于“什么时候该停下来”——错误分类不准、请求 body 不可重放、context 生命周期失控、退避状态被意外共享。这四点里只要踩中一个,重试机制就可能从系统的“兜底卫士”变成引发雪崩的“故障放大器”。
