应自己封装 http.Client,因其可配置超时、连接复用、重试、日志及中间件,避免默认客户端无超时、连接耗尽、请求卡死等问题。
http.Client 而不是直接用默认客户端Go 的 http.DefaultClient 看似方便,但实际项目中几乎不能直接用:它没有超时控制(底层 http.Transport 的 DialContext 和 ResponseHeaderTimeout 全是 0),复用连接能力弱,默认不带重试和日志,更无法注入中间件逻辑。微服务调用、第三方 API 集成、压测场景下,裸用会导致连接耗尽、请求卡死、错误难追踪。
Timeout 字段只作用于整个请求生命周期(Go 1.19+ 才支持),但底层 TCP 连接、TLS 握手、首字节等待仍可能无限挂起http.Transport,一旦某个域名 DNS 解析失败或 TLS 协商卡住,可能阻塞后续所有请求User-Agent、Authorization、请求 ID、链路追踪 headerhttp.Transport + 可配置的 http.Client
关键不是“写个新 client”,而是控制底层传输行为。必须显式构造 http.Transport 并设置以下参数:
MaxIdleConns 和 MaxIdleConnsPerHost 设为非 0 值(如 100),否则 HTTP/1.1 连接不会复用IdleConnTimeout 建议设为 30s,避免长连接被服务端主动断开后 client 还在傻等TLSHandshakeTimeout 必须设(如 10 * time.Second),否则 TLS 握手失败会卡住整个 goroutinehttp.ProxyFromEnvironment 或自定义代理函数,别忽略公司内网环境transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
Proxy: http.ProxyFromEnvironment,
// 若需跳过证书校验(仅测试),加这一行:TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}Go 的 http.Client 本身不提供重试机制,也不能在请求中途安全中断正在写的 body。正确做法是在调用层封装一个带重试逻辑的函数,且每次重试都新建 *http.Request —— 因为 req.Body 是单次读取的,重用会 panic。
context.WithTimeout 或 context.WithDeadline 控制单次请求,不是给整个 client 设 timeoutGET、HEAD、OPTIONS)或明确可重试的状态码(502/503/504)重试,POST 默认不重试func (c *HTTPClient) DoWithRetry(ctx context.Context, req *http.Request, maxRetries int) (*http.Response, error) {
var err error
for i := 0; i <= maxRetries; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
resp, err := c.client.Do(req.WithContext(ctx))
if err == nil
&& isRetriableStatusCode(resp.StatusCode) {
_ = resp.Body.Close()
if i == maxRetries {
break
}
time.Sleep(time.Duration(math.Pow(2, float64(i))) * 100 * time.Millisecond)
// 注意:这里必须重建 req,不能复用!
req, _ = http.NewRequestWithContext(ctx, req.Method, req.URL.String(), req.Body)
continue
}
if err == nil {
return resp, nil
}
}
return nil, err
}封装时最常漏掉三件事:一是 resp.Body 必须手动 Close(),否则连接永远不释放;二是 json.Unmarshal 前没检查 resp.StatusCode,导致 4xx/5xx 响应体也被当正常 JSON 解析;三是拼 URL 时没做 url.PathEscape,含中文或特殊字符的 path 直接 400。
Do 后的 resp.Body 必须用 defer resp.Body.Close(),哪怕后面要 ioutil.ReadAll
json.NewDecoder(resp.Body).Decode(&v) 就完事,先判断 resp.StatusCode
url.PathEscape,query 参数用 url.QueryEscape,别手拼字符串封装不是为了造轮子,是把 Go HTTP 底层那些「默认不安全」的选项,变成项目里可审计、可配置、可观测的确定行为。越早统一 client 行为,后期排查超时、连接数暴涨、证书错误这类问题就越省力。