
Go 项目调用外部接口时,鉴权、日志和重试很容易散落在业务代码里。net/http 的 RoundTripper 正适合承接这些横切逻辑:它位于 http.Client 和底层连接之间,负责执行一次 HTTP 事务,业务侧仍然使用标准的 client.Do(req)。
RoundTripper 的接口非常小:
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
官方文档给出的边界很重要:RoundTrip 只代表单次 HTTP 事务,不应该解释响应内容。即使服务端返回 500,只要响应已经拿到,通常也应返回 Response 而不是 error。重定向、Cookie、认证等高层策略,也不属于底层 RoundTripper 的默认职责。
另一个边界是并发安全。RoundTripper 必须允许多个 goroutine 同时调用,所以封装层里如果保存 token 缓存、指标计数或刷新状态,需要自行加锁或使用并发安全结构。
http.Client 和 Transport 应尽量复用,才能复用底层连接池。每一层封装可以写成接收 next 的中间件:
type TransportFunc func(*http.Request) (*http.Response, error)
func (f TransportFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
type Middleware func(http.RoundTripper) http.RoundTripper
链式组装时,从后往前包裹:
func Chain(rt http.RoundTripper, ms ...Middleware) http.RoundTripper {
for i := len(ms) - 1; i >= 0; i-- { rt = ms[i](rt) }
return rt
}
客户端创建只保留两个重点:超时放在 Client,横切能力放进 Transport。
client := &http.Client{
Timeout: 3 * time.Second,
Transport: Chain(http.DefaultTransport,
Logging(log), Retry(2), Auth(token)),
}
日志层放在最外面,可以记录最终结果;重试层包住鉴权层,每次重试都能重新注入最新 token。
鉴权层不要直接修改传入的 req,因为请求对象可能被外层复用。更稳妥的方式是克隆请求后再改 Header:
func Auth(token func() string) Middleware {
return func(next http.RoundTripper) http.RoundTripper {
return TransportFunc(func(req *http.Request) (*http.Response, error) {
r := req.Clone(req.Context())
r.Header.Set("Authorization", "Bearer "+token())
return next.RoundTrip(r)
})
}
}
Request.Clone 对 Body 只是浅拷贝。鉴权层不读取 Body,所以没有问题;重试层如果要再次发送请求,则必须处理请求体回放。
日志层适合记录方法、路径、状态码、耗时和错误,不适合读取响应体。Response.Body 是流,一旦日志层读掉,业务层就拿不到完整内容。
return TransportFunc(func(req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := next.RoundTrip(req)
code := 0
if resp != nil { code = resp.StatusCode }
log.InfoContext(req.Context(), "http client",
"method", req.Method, "path", req.URL.Path,
"status", code, "cost", time.Since(start), "err", err)
return resp, err
})
示例只记录 URL.Path,避免把敏感查询参数打进日志。若需要记录响应体片段,更适合在业务解析失败时做有限字节采样。
重试最容易写错的是请求体。Body 是一次性流,第一次发送后就可能被消费。官方文档提供的关键字段是 GetBody,客户端请求可通过它获得新的 Body。
func cloneForRetry(req *http.Request) (*http.Request, error) {
r := req.Clone(req.Context())
if req.Body == nil || req.Body == http.NoBody { return r, nil }
if req.GetBody == nil { return nil, fmt.Errorf("body not replayable") }
body, err := req.GetBody()
if err != nil { return nil, err }
r.Body = body
return r, nil
}
使用 http.NewRequest 且 Body 来自 *bytes.Buffer、*bytes.Reader、*strings.Reader 时,标准库会设置 GetBody。如果 Body 来自文件、网络流或压缩流,通用重试层不应盲目重试。
重试前还要关闭上一次响应体,否则连接可能无法复用。可以有限度丢弃一部分响应体,再关闭 Body,避免巨大响应被无限制读取。
func closeBody(resp *http.Response) {
if resp == nil || resp.Body == nil { return }
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 512<<10))
_ = resp.Body.Close()
}
重试条件应保守:网络错误、429、5xx 常见于临时失败;POST 是否能重试,要看是否有幂等键或业务去重机制。
func retryable(resp *http.Response, err error) bool {
if err != nil { return true }
if resp == nil { return false }
return resp.StatusCode == 429 || resp.StatusCode >= 500
}
核心循环保持短小:
for i := 0; i <= max; i++ {
r, e := cloneForRetry(req)
if e != nil { return nil, e }
resp, err = next.RoundTrip(r)
if !retryable(resp, err) || i == max { return resp, err }
closeBody(resp)
time.Sleep(time.Duration(i+1) * 100 * time.Millisecond)
}
生产版本还应加入上下文取消判断、抖动退避、方法白名单、最大总耗时和指标统计。尤其是 req.Context() 已取消时,重试层应尽快退出。
RoundTripper 的价值,是把 HTTP 客户端的横切能力收敛到标准扩展点里:鉴权层改 Header,日志层观察元数据,重试层处理可回放请求和临时失败。
需要守住的边界也很清晰:不要把状态码误当网络错误,不要在日志层消费响应体,不要重试不可回放的 Body,不要忘记关闭失败响应的 Body。处理好这些细节,业务代码就能继续保持简单的 client.Do(req)。