Go 的 http.Client 不自动重试或解包错误语义,需手动区分网络层错误(err!=nil)、HTTP 状态码错误(err==nil 且 resp!=nil)及业务错误;必须读完并关闭 resp.Body 防连接泄漏。

Go 的 http.Client 本身不自动重试、不自动解包错误语义,所有外部 API 错误都得你手动识别、分类、响应——这不是“有没有错误”的问题,而是“错在哪一层”的问题。
HTTP 状态码 ≠ Go 错误值
很多人以为 resp.StatusCode >= 400 就是“请求失败”,但 Go 的 http.Do() 在网络层出问题(如 DNS 失败、连接超时、TLS 握手失败)时根本不会返回 *http.Response,而是直接返回非 nil 的 error。这时候 resp 是 nil,读 resp.StatusCode 会 panic。
- 先检查
err != nil:这是网络层/客户端层错误(net.OpError、net/url.Error、context.DeadlineExceeded等) - 再检查
resp.StatusCode:仅当err == nil且resp != nil时才安全 - 别用
http.StatusText(code)判断业务逻辑;401 和 503 都是“错误”,但恢复策略完全不同
区分三类错误并分别处理
外部 API 错误至少要拆成三类来应对,否则日志看不出是该重试、该告警,还是该跳过:
-
临时性错误(可重试):如
502、503、504、context.DeadlineExceeded、net.ErrClosed—— 建议加退避重试(用github.com/cenkalti/backoff/v4) -
客户端错误(不可重试):如
400(参数错)、401(token 过期)、403(权限不足)、404(资源不存在)—— 应记录原始请求体 + 响应体,便于排查 -
服务端逻辑错误(需人工介入):如
5xx但响应体含{"code":"INTERNAL_ERROR","trace_id":"..."}—— 要提取trace_id并上报到监控系统
不要忽略 resp.Body 的关闭和读取
即使 resp.StatusCode >= 400,只要 resp 不为 nil,resp.Body 就必须被读完并关闭,否则连接会被保留在连接池里,最终耗尽 http.MaxIdleConnsPerHost。
立即学习“go语言免费学习笔记(深入)”;
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close() // 即使 status != 200 也要 defer
body, _ := io.ReadAll(resp.Body) // 必须读完,否则连接不释放
if resp.StatusCode >= 400 {
log.Printf("API error %d: %s", resp.StatusCode, string(body))
return fmt.Errorf("api failed: %d", resp.StatusCode)
}
- 用
io.ReadAll或io.Copy(io.Discard, resp.Body)强制消费 body - 别在
if resp.StatusCode 分支里才defer resp.Body.Close()—— 这会导致 4xx/5xx 场景下泄漏 - 如果响应体很大,用
io.LimitReader控制最大读取长度,防 OOM
Context 超时和取消必须显式传递
不带 context.Context 的请求等于裸奔:无法统一控制超时、无法在父任务取消时中止子请求、无法注入 trace ID。
- 永远用
client.Do(req.WithContext(ctx)),而不是client.Do(req) - 设置合理的超时:总超时(
context.WithTimeout)+ 连接超时(http.Client.Timeout)要协同,避免前者未触发而后者已返回 - 在错误处理中检查
errors.Is(err, context.Canceled)或errors.Is(err, context.DeadlineExceeded),这类错误通常不应重试
最常被忽略的是:很多团队把错误响应体当“无用信息”直接丢弃,但恰恰是 400 Bad Request 的响应体里藏着字段校验失败详情,422 Unprocessable Entity 里有结构化错误码——这些才是调试外部 API 集成问题的第一手线索。









