go的http.client默认不重试,必须手动实现;仅底层tcp错误由transport内部隐式重试一次,不可控且不覆盖常见失败;重试逻辑应置于业务侧,区分可重试(如net.operror、5xx)与不可重试错误(如4xx),并配合context超时与指数退避。

HTTP客户端默认不重试,必须手动实现
Go 的 http.Client 在遇到网络错误(如连接超时、DNS失败)或 5xx 响应时,**不会自动重试**——它只在极少数底层 TCP 错误下由 Transport 内部重试一次(比如写入半开连接),但这个行为不可控、不透明,且不覆盖常见失败场景。你看到的“请求失败就结束了”,就是它的默认逻辑。
重试必须自己加:要么包装 Do() 方法,要么用中间件式封装,或者借助第三方库(如 gofork/req 或 hashicorp/go-retryablehttp)。但依赖库会引入额外行为(比如自动处理 429、修改 Header),反而让问题更难定位。
- 不要假设
http.DefaultClient有重试能力,它没有 - 别在
http.Transport层试图通过MaxIdleConnsPerHost或IdleConnTimeout“间接提升稳定性”——这些只管连接复用,和重试无关 - 重试逻辑必须放在业务调用侧,而不是塞进自定义
RoundTripper里(除非你清楚每种 error 的来源和可重试性)
判断哪些错误可以安全重试
不是所有失败都能重试。盲目重试 400、401、403 或 404,只会放大问题;而对 net.OpError(连接拒绝、超时)、url.Error(DNS 失败)、或 502/503/504 这类服务端临时故障,重试才有意义。
关键点在于:**区分客户端错误 vs 服务端临时错误 vs 网络层中断**。Go 中最常被误判的是 context.DeadlineExceeded——它可能是请求超时,也可能是服务端处理太久。前者可重试,后者不一定。
立即学习“go语言免费学习笔记(深入)”;
- 可重试:
net.OpError(含dial tcp: i/o timeout)、url.Error(lookup xxx: no such host)、http.ErrUseLastResponse(极少)、5xx 响应状态码 - 不可重试:
400、401、403、404、422;io.EOF(可能已部分读取响应体);context.Canceled(调用方主动取消) - 需谨慎:
500(可能是服务端 bug,重试无用)、429(应先看Retry-Afterheader)
用 context.WithTimeout + 指数退避控制重试节奏
单纯 for 循环重试 + 固定 sleep,容易打爆下游或触发限流。必须结合 context 控制整体超时,并用指数退避(exponential backoff)拉开重试间隔。
注意:每次重试都应新建一个带独立 deadline 的 context,而不是复用原始 context——否则第一次超时后,后续重试还没开始就已失效。
func doWithRetry(req *http.Request, maxRetries int) (*http.Response, error) {
var resp *http.Response
var err error
baseDelay := 100 * time.Millisecond
for i := 0; i <= maxRetries; i++ {
ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
req = req.Clone(ctx)
resp, err = http.DefaultClient.Do(req)
cancel()
if err == nil && resp.StatusCode >= 500 && resp.StatusCode < 600 {
if i < maxRetries {
time.Sleep(baseDelay * time.Duration(1<<i)) // 100ms, 200ms, 400ms...
continue
}
}
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
if canRetry(err) {
if i < maxRetries {
time.Sleep(baseDelay * time.Duration(1<<i))
continue
}
}
break
}
return resp, err
}Body 被读取后无法重放,POST/PUT 请求要特别处理
HTTP 请求体(req.Body)是单次读取的 io.ReadCloser。一旦 Do() 内部读过,下次重试时再传同一个 req,body 就是空的——导致服务端收到空 payload,返回 400 或 500,形成“假失败”。
解决方法只有两个:要么把原始数据缓存为字节切片并每次重建 bytes.Reader,要么用 strings.NewReader / bytes.NewBuffer 重新构造 body。JSON 场景下,通常意味着你要把 struct 序列化结果存起来,而不是反复调用 json.Marshal。
- GET 请求没 body,无需处理
- POST/PUT 表单数据:用
strings.NewReader(formStr)替代原strings.NewReader引用 - JSON 请求:提前
data, _ := json.Marshal(payload),重试时用bytes.NewReader(data) - 绝对不要在重试循环里重复调用
json.Marshal——它可能 panic,且浪费 CPU
重试真正麻烦的从来不是逻辑,而是 body 状态管理。漏掉这一条,重试看起来在跑,实际每次都在发空请求。










