Go的http.Client默认完全不重试,所有重试必须手动实现:需新建请求、区分可重试错误、用指数退避+抖动、正确处理Body重放与context生命周期。

重试不是 client.Do 自带的功能,必须手动加逻辑
Go 的 http.Client 默认**完全不重试**——哪怕只是 DNS 解析失败、TCP 连接超时、TLS 握手卡住,它也直接返回 url.Error 或 net.OpError,不会尝试第二次。你看到的“好像重试了”,大概率是服务端 302 重定向(仅限 GET/HEAD)或上层代理行为,不是 client 本身干的。
- 所有重试都得你自己写:用
for循环 +time.Sleep,或者包装RoundTripper - 别改
http.DefaultClient,容易污染全局;建议封装结构体或函数,比如RetryClient.Do() - 每次重试前必须新建
*http.Request(用req.Clone(ctx)),否则req.Body已被读过会变nil或 EOF - POST/PUT 等非幂等请求,重试前务必确认服务端支持幂等(如通过
Idempotency-Key头),否则可能重复下单
只对可重试错误重试,4xx 大部分不能碰
盲目重试等于给下游加压。真正该重试的就两类:网络层临时失败 和 服务端临时错误。其他一律跳过。
- ✅ 可重试:
context.DeadlineExceeded、net.OpError(含"i/o timeout"、"connection refused")、HTTP 状态码500/502/503/504、429(Too Many Requests)、408(Request Timeout) - ❌ 不可重试:
400(参数错)、401/403(鉴权失败)、404(资源不存在)、405(方法不支持)——这些是客户端问题,重试没意义 - 判断方式推荐用
errors.As(err, &netErr)检查底层错误,再用resp.StatusCode判断状态码,不要靠strings.Contains(err.Error(), "...")做模糊匹配 - 拿到
5xx响应后,记得先io.Copy(io.Discard, resp.Body)再resp.Body.Close(),否则连接无法复用,后续请求可能卡住
用指数退避 + 抖动,别写死 time.Sleep(1 * time.Second)
固定间隔重试在并发场景下极易引发“重试风暴”——大量请求在同一毫秒涌向下游,反而把本就抖动的服务压垮。
- 基础做法:
delay = time.Duration(float64(base) * math.Pow(2, float64(attempt))),比如 base=100ms → 第1次100ms、第2次200ms、第3次400ms - 必须加抖动:
delay += time.Duration(rand.Int63n(int64(jitter))),例如 ±100ms,让重试时间散开 - 设上限值,避免单次重试等待太久(比如最大 delay 不超过 1s),同时用外层
context.WithTimeout控制整个重试流程总耗时 - 别在循环里
defer cancel()——defer只对最后一次生效;每次迭代都要新建子 context 并及时调用cancel()
Body 不可重放是重试失败最常见原因
绝大多数重试失败不是因为策略写错,而是 req.Body 被读过一次后就再也拿不到数据了。标准库的 http.Request 不做缓存,这是设计使然。
立即学习“go语言免费学习笔记(深入)”;
- 如果原始 Body 是
strings.NewReader("...")或bytes.NewReader(buf),它天然可重放,放心重试 - 如果 Body 来自文件(
os.Open)、网络流(http.Request.Body转发)或未缓存的io.Reader,重试前必须重置:先bodyBytes, _ := io.ReadAll(req.Body),再每次重试时重建req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - 别用
req.Body = ioutil.NopCloser(bytes.NewReader(...))——ioutil已弃用,用io.NopCloser - 对大 Body(如上传文件),预读全部内容到内存有风险;此时更稳妥的做法是换用
retryablehttp库,它内部做了 buffer 管理
重试看着简单,但 Body 重放、错误分类、退避控制、context 生命周期这四块,漏掉任何一块都会在压测或上线后突然崩给你看。


















