Go 的 HTTP 客户端:超时、重试和那些容易被忽略的细节
调外部接口的小工具,出问题最多的不是业务逻辑,是网络。网络会慢、会断、会返回 429,而默认的 http.Client 一样都不管:没有超时,不会重试,出错就把错误原样抛给调用方。
这篇记一下加这两样东西时最容易踩的坑。
一、超时不是设一个数字就完了
很多代码只写了这一行:
client := &http.Client{Timeout: 20 * time.Second}
Timeout 管的是整个请求,包括连接、TLS 握手、等响应头、读响应体。看着挺全,实际有个问题:它没有区分阶段,排查时你只知道「20 秒超时了」,不知道卡在哪一步。
更重要的一点是它是硬上限,不能按请求调整。同一份代码里,一个查列表的请求和一个上传文件的请求,合理的超时差着数量级。
更好的写法是用 context 控制,Timeout 只做兜底:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
这样超时跟着调用走,上层想调就调,取消信号也能一路传下去。
二、只重试该重试的错误
重试写错最常见的方式是「失败就再来一次」。有些错误重试一万次也不会好:
400、401、403、404:请求本身有问题,重试是浪费- 证书错误、域名解析失败:多半是配置问题,重试没用
值得重试的只有三类:
func retryable(status int, err error) bool {
if err != nil {
return true // 网络层错误,连接被重置、超时
}
return status == 429 || status >= 500
}
429 单独拿出来说,因为它是有约定的:响应头里通常带 Retry-After,告诉你要等多少秒。这时候按自己的退避算没有意义,按它说的等:
if d := resp.Header.Get("Retry-After"); d != "" {
if secs, err := strconv.Atoi(d); err == nil {
time.Sleep(time.Duration(secs) * time.Second)
}
}
三、POST 请求要能重放
这是最隐蔽的一个坑。http.Request 的 Body 是个只能读一次的流。第一次请求发出去,body 就读完了;重试时再读,拿到的是空内容,服务端可能会返回一个莫名其妙的 400,看起来像是重试导致的新问题。
两种解决办法。第一种是传 bytes.Reader,标准库会自动设置 GetBody:
body := []byte(`{"page_no":1}`)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
// 此时 req.GetBody 不为空,客户端内部能自己重建
第二种是自己在重试循环里每次重新构造请求:
for attempt := 1; attempt <= max; attempt++ {
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
// ...
}
后者更直白,前者更省事。别做的是传一个已经被读过一次的 Body。
四、退避要带抖动
固定间隔的重试在批量场景下很危险:一百个请求同时失败,全部睡 1 秒,然后同时醒来,把对端再打一遍。对端本来就已经过载了,这一下更糟。
常见的做法是指数退避加随机抖动:
base := 500 * time.Millisecond
d := base << (attempt - 1) // 500ms, 1s, 2s ...
d += time.Duration(rand.Int63n(int64(d / 2))) // 加 0~50% 抖动
抖动的作用是把同时失败的一批请求打散。没有它,指数退避只能解决单个请求的问题,解决不了并发的问题。
五、重试次数要有边界,而且要能看见
重试上限很好理解,通常 3 次足够。容易被忘的是第二件事:日志里要能看出这是第几次。
slog.Warn("请求失败,准备重试", "url", url, "status", status, "attempt", attempt, "wait_ms", d.Milliseconds())
没有这行日志,事后你只能看到一条最终失败,不知道程序在放弃前努力过几次、每次等了多久。这两个数字决定了下次该调超时还是该调上限。
六、小结
四件事:用 context 控制超时、按状态码区分是否重试、POST 请求准备好可重放的 body、退避带抖动并记录尝试次数。
都写下来大概几十行。真正花时间的地方不在实现,在确认哪些错误值得重试——这个判断每换一个上游服务,可能都要重新做一遍。
- 点赞
- 收藏
- 关注作者
评论(0)