Go HTTP 客户端中间件入门:给外部调用加日志、重试和请求 ID

本文讲解 Go HTTP 客户端 RoundTripper 的基本模式,用标准库实现外部请求日志、请求 ID、重试和超时边界。

客户端也需要中间件

我们经常给 HTTP 服务端写中间件:请求日志、鉴权、恢复 panic、请求 ID。其实 HTTP 客户端也有类似需求。调用外部 API 时,你可能希望统一加 User-Agent、加请求 ID、记录耗时、对 502/503 做有限重试。Go 标准库通过 http.RoundTripper 支持这种扩展。

http.Client 里有一个字段:

Transport http.RoundTripper

RoundTripper 负责执行一次 HTTP 请求。我们可以包装默认 transport,在请求前后做事情。这篇文章用标准库实现几个简单客户端中间件。

RoundTripper 是什么

接口:

type RoundTripper interface {
	RoundTrip(*http.Request) (*http.Response, error)
}

最小包装:

type LoggingTransport struct {
	Base http.RoundTripper
}

func (t LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	base := t.Base
	if base == nil {
		base = http.DefaultTransport
	}

	start := time.Now()
	resp, err := base.RoundTrip(req)
	if err != nil {
		log.Printf("http request method=%s url=%s error=%v duration=%s",
			req.Method, req.URL.String(), err, time.Since(start))
		return nil, err
	}

	log.Printf("http request method=%s url=%s status=%d duration=%s",
		req.Method, req.URL.String(), resp.StatusCode, time.Since(start))
	return resp, nil
}

使用:

client := &http.Client{
	Timeout:   5 * time.Second,
	Transport: LoggingTransport{},
}

现在通过这个 client 发出的请求都会记录日志。

添加请求 ID

type HeaderTransport struct {
	Base      http.RoundTripper
	RequestID string
}

func (t HeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	base := t.Base
	if base == nil {
		base = http.DefaultTransport
	}

	cloned := req.Clone(req.Context())
	if t.RequestID != "" {
		cloned.Header.Set("X-Request-ID", t.RequestID)
	}
	cloned.Header.Set("User-Agent", "my-go-client/1.0")

	return base.RoundTrip(cloned)
}

为什么要 clone request?因为请求可能被复用或被调用方继续使用。中间件修改请求时,克隆更稳。

组合:

transport := LoggingTransport{
	Base: HeaderTransport{
		RequestID: "req-123",
	},
}

client := &http.Client{
	Timeout:   5 * time.Second,
	Transport: transport,
}

请求会先经过 LoggingTransport,再经过 HeaderTransport,最后到默认 transport。

简单重试

重试要谨慎,只对幂等请求或明确支持幂等键的请求使用。GET 通常比较适合,POST 创建订单就要小心。

type RetryTransport struct {
	Base     http.RoundTripper
	Attempts int
}

func (t RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	base := t.Base
	if base == nil {
		base = http.DefaultTransport
	}
	attempts := t.Attempts
	if attempts <= 0 {
		attempts = 3
	}

	var lastErr error
	for i := 0; i < attempts; i++ {
		resp, err := base.RoundTrip(req)
		if err != nil {
			lastErr = err
		} else if resp.StatusCode == http.StatusBadGateway ||
			resp.StatusCode == http.StatusServiceUnavailable ||
			resp.StatusCode == http.StatusGatewayTimeout {
			resp.Body.Close()
			lastErr = fmt.Errorf("temporary status: %d", resp.StatusCode)
		} else {
			return resp, nil
		}

		select {
		case <-time.After(time.Duration(i+1) * 200 * time.Millisecond):
		case <-req.Context().Done():
			return nil, req.Context().Err()
		}
	}
	return nil, lastErr
}

这个例子为了入门保持简单。真实重试还要考虑请求体是否能重复读取、是否有幂等键、是否应该限制总耗时。

组合顺序会影响日志

Transport 像洋葱一样一层包一层,顺序不同,看到的行为也不同。比如你把日志包在重试外面,日志可能只记录一次整体调用;如果把日志放在重试里面,每次尝试都会记录一行。两种都合理,取决于你想观察什么。

client := &http.Client{
	Timeout: 5 * time.Second,
	Transport: LoggingTransport{
		Base: RetryTransport{
			Base: http.DefaultTransport,
		},
	},
}

上面这种组合更像“记录业务视角的一次调用”。如果调换顺序:

client := &http.Client{
	Timeout: 5 * time.Second,
	Transport: RetryTransport{
		Base: LoggingTransport{
			Base: http.DefaultTransport,
		},
	},
}

日志会更接近“记录每一次网络尝试”。线上排查外部依赖抖动时,后者信息更多;日常访问日志里,前者更简洁。中间件的好处是组合灵活,代价是你必须把顺序写得明确,最好在构造 client 的地方集中完成,不要散落在各个业务函数里。

小结

Go HTTP 客户端可以通过 RoundTripper 实现中间件式扩展。日志、请求头、请求 ID、重试都可以围绕 RoundTrip 包装。组合时要注意顺序,修改请求时最好 clone,重试时要确认请求是否幂等。

客户端中间件能让外部调用更统一,但不要把复杂策略藏得太深。超时、日志、错误和重试规则都应该清楚可见。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页