Go 的 HTTP 客户端:超时、重试和那些容易被忽略的细节

举报
yd_271078175 发表于 2026/11/04 09:07:06 2026/11/04
【摘要】 调外部接口的小工具,出问题最多的不是业务逻辑,是网络。网络会慢、会断、会返回 429,而默认的 http.Client 一样都不管:没有超时,不会重试,出错就把错误原样抛给调用方。

调外部接口的小工具,出问题最多的不是业务逻辑,是网络。网络会慢、会断、会返回 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、退避带抖动并记录尝试次数。

都写下来大概几十行。真正花时间的地方不在实现,在确认哪些错误值得重试——这个判断每换一个上游服务,可能都要重新做一遍。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。