Go 基础体系 · 第 81/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go Resty HTTP 客户端:请求封装、重试、认证与可观测性

本文以 Go 1.26.4 和稳定版 github.com/go-resty/resty/v2 v2.16.5 为基准。Resty 在 net/http 之上提供 base URL、JSON 编解码、认证、hook、重试和调试辅助,但 DNS、TCP/TLS、HTTP/2、连接池与响应 body 最终仍由标准库 Transport/Client 承担。理解这一层关系,才能正确设置生命周期、超时和故障边界。

Resty 适合调用 JSON API,并不意味着每个下游共用一个万能客户端。推荐为每个远端服务创建长期复用的 *resty.Client,再用窄类型封装该服务的 DTO、路径、认证和错误契约;每次调用创建 Request 并传入 context。

1. Resty 在 net/http 上增加了什么

一次调用大致经过:创建 Request、合并 Client 默认配置、执行 before-request middleware、序列化 body、通过 http.Client.Do 发送、读取/解析 body、执行 after-response middleware、根据条件重试,最终返回 Response 和 error。

Resty Request
  -> headers/auth/path/query/body encode
  -> hooks and retry loop
  -> http.Client
  -> http.Transport connection pool
  -> remote server
  -> bounded response/decode/error mapping

Resty 的 err 主要代表请求构造、传输、读取或解析失败。HTTP 404、429、500 是已经成功收到响应,通常 err == nil,必须检查 Response.IsError() 或状态码。不要写成“err 为 nil 就业务成功”。

2. 固定版本与最小 Client

模块路径必须带 /v2,安装并检查实际选择版本:

go get github.com/go-resty/resty/v2@v2.16.5
go mod tidy
go list -m github.com/go-resty/resty/v2

Client 和底层 Transport 都并发安全且应长期复用。resty.New() 使用自己的标准库 Client/Transport 配置;需要精确连接池参数时,克隆默认 Transport 后注入。

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConns = 100
transport.MaxIdleConnsPerHost = 20
transport.MaxConnsPerHost = 50
transport.IdleConnTimeout = 90 * time.Second
transport.ResponseHeaderTimeout = 3 * time.Second

client := resty.NewWithClient(&http.Client{
	Transport: transport,
	Timeout:   5 * time.Second,
}).SetBaseURL("https://article-api.example.com").
	SetHeader("User-Agent", "wrblog/1.0")

关闭服务时可对自己持有的 *http.Transport 调用 CloseIdleConnections。不要每请求新建 Client,否则失去 keep-alive,反复 DNS/TCP/TLS,并可能耗尽临时端口。

3. Client、Request、Response 的所有权

Client 保存默认 header、认证、base URL、hook、重试策略和底层 HTTP Client;Request 保存本次 path/query/header/body/result/context;Response 包装 *http.Response、body 字节、耗时与尝试次数。

共享 Client 后,启动完成就把配置视为不可变。不要在并发请求中调用 SetHeaderSetRetryCount 或注册 hook;每请求差异使用 client.R().SetHeader(...)。Request 不是并发对象,也不能重复用于同时发送。

var article Article
response, err := client.R().
	SetContext(ctx).
	SetPathParam("id", articleID).
	SetResult(&article).
	Get("/v1/articles/{id}")
if err != nil {
	return Article{}, fmt.Errorf("get article: %w", err)
}
if response.IsError() {
	return Article{}, decodeRemoteError(response)
}
return article, nil

SetResult 的目标只在当前请求使用,不能把同一指针交给多个并发请求。返回领域值可避免调用者依赖 Resty 类型。

5. JSON 请求、响应与类型契约

SetBody 接受结构体时会按配置序列化,自动设置适当 Content-Type;SetResult 对成功响应解码,SetError 可为错误响应提供类型。请求/响应 DTO 显式写 JSON tag,不复用数据库实体,避免 Mass Assignment 和内部字段泄露。

type CreateArticleRequest struct {
	Title string `json:"title"`
}

type RemoteError struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

var (
	created   Article
	remoteErr RemoteError
)
response, err := client.R().
	SetContext(ctx).
	SetBody(CreateArticleRequest{Title: title}).
	SetResult(&created).
	SetError(&remoteErr).
	Post("/v1/articles")

响应 Content-Type 应验证为允许的 JSON 类型。远端返回 200 HTML 登录页时,解码错误必须保留状态和受限摘要,但不能把整个 body 或凭证写日志。JSON 解码默认行为(未知字段、重复字段、数字精度)要用契约测试固定;强严格要求可获取 body 后用自定义 decoder。

6. Context、Client.Timeout 与分层超时

每次调用 SetContext(ctx),让调用方取消和 deadline 传播到标准库 Request。Client.Timeout 是从开始请求到读完响应 body 的总保险;context deadline 与它先到者生效。Transport 还可限制连接、TLS 和响应头阶段。

func (c *ArticlesClient) Get(ctx context.Context, id string) (Article, error) {
	opCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()
	// 使用 opCtx 调用 Resty;父 deadline 更早时不会被延长。
	return c.get(opCtx, id)
}

总预算包括连接池排队、DNS、TCP、TLS、服务端处理、下载、解码、重试和退避。给每次 attempt 设置完整 800ms 再重试三次会突破用户预算;重试前应检查 ctx.Err() 和剩余时间。取消不是远端事务回滚证明,写请求超时可能已经提交。

错误分类用 errors.Is(err, context.Canceled/DeadlineExceeded)net.Error 和明确的状态码,不解析错误字符串。同一个失败在封装层包装返回,由最终协议边界记录一次。

8. Resty hook 和 middleware 的边界

OnBeforeRequest 可注入 trace header、签名或统一请求 ID;OnAfterResponse 可记录状态和耗时;OnError 处理传输/解析错误。Hook 顺序会影响签名内容和日志,必须在启动时注册并写测试。

client.OnBeforeRequest(func(_ *resty.Client, request *resty.Request) error {
	request.SetHeader("X-Client-Version", "wrblog/1.0")
	return nil
})
client.OnAfterResponse(func(_ *resty.Client, response *resty.Response) error {
	if response.StatusCode() == http.StatusNoContent && len(response.Body()) != 0 {
		return errors.New("204 response contains a body")
	}
	return nil
})

Hook 在每次 attempt 是否执行、失败是否触发重试,要依据当前版本测试,不凭想象。签名含时间戳/nonce 时,每次重试必须重新生成;幂等键则必须在全部 attempts 保持相同。Hook 不应启动 goroutine、执行无界 I/O 或记录 Authorization/body。

9. 重试资格先于重试配置

只有同时满足以下条件才重试:操作幂等或携带服务端支持的幂等键;错误被协议定义为瞬时;还有总 deadline;请求 body 可重放;attempt 有上限。GET 通常可重试,POST 创建不能仅因 503/连接重置就自动重发。

client.SetRetryCount(2).
	SetRetryWaitTime(100 * time.Millisecond).
	SetRetryMaxWaitTime(time.Second).
	AddRetryCondition(func(response *resty.Response, err error) bool {
		if err != nil {
			return response != nil && response.Request.Context().Err() == nil
		}
		return response.StatusCode() == http.StatusTooManyRequests ||
			response.StatusCode() >= http.StatusBadGateway
	})

这里配置的 2 表示初次请求之外最多重试次数,最终可能共 3 次。实际策略还要限制方法,不能把 Client 级条件机械用于所有 POST。429/503 的 Retry-After 应在总预算和本地 cap 内尊重;退避带抖动以避免实例同步重试。

每层只能有一个明确重试责任方。网关、业务服务、Resty 和服务网格同时各重试两次,会把一次请求放大到多次。监控既记录逻辑调用数,也记录 attempt 数和最终结果。

10. 请求 body 重放与幂等键

结构体/字节切片 body 通常可重新编码或重放;流式 io.Reader 可能已经消费,无法安全重试。大上传不应为重试全部载入内存,应设计分块、可定位读取或服务端上传会话。

写操作要重试时,客户端生成高熵幂等键,服务端把 key 与租户、请求摘要、状态和原响应放在同一事务语义中。相同 key 不同 payload 返回冲突,处理中状态可查询。

POST /v1/payments HTTP/1.1
Idempotency-Key: 9d55f69c-8b52-44eb-91ad-56ab20ddb365
Content-Type: application/json

{"order_id":"o-42","amount_cents":9900}

幂等键不是认证,也不能永久增长。服务端 TTL 覆盖最大客户端重试窗口,客户端在一次逻辑调用全部 attempts 中复用同一 key。超时后若不确定结果,优先按 key 查询状态,不盲目创建新 key。

11. 认证、token 刷新与并发

固定 Bearer token 可设在 Client 或 Request,但生产常需轮换。认证 provider 应从安全存储获得短期 token,缓存过期时间,并使用 singleflight/互斥避免并发 401 触发刷新风暴。刷新请求本身有独立 timeout,且不能使用同一个会自动刷新、导致递归的 hook。

401 不应无条件刷新重试:可能是 audience 错、权限不足或账号禁用。最多刷新一次,并只对可重放请求尝试。Access token 永不写日志,Resty Debug 在生产默认关闭。

token, err := c.tokens.Token(ctx)
if err != nil {
	return Article{}, fmt.Errorf("get access token: %w", err)
}
response, err := c.http.R().
	SetContext(ctx).
	SetAuthToken(token).
	SetResult(&article).
	Get("/v1/articles/{id}")

API key、HMAC 和 OAuth2 签名要明确 canonical path/query/body、时钟偏差与 nonce。Hook 修改 body/header 的顺序必须在签名前固定。

12. 响应大小、body 与内存控制

Resty 为便于 Body() 和自动解码,通常把响应读入内存。对不可信或可能很大的响应,必须建立上限;仅设置 Client.Timeout 不能防止快速返回超大 body 耗尽内存。可通过自定义 Transport 包装响应 body 为限长 reader,或对下载场景使用 SetOutput/标准库流式路径并核对错误。

type limitTransport struct {
	next  http.RoundTripper
	limit int64
}

func (t limitTransport) RoundTrip(request *http.Request) (*http.Response, error) {
	response, err := t.next.RoundTrip(request)
	if err != nil {
		return nil, err
	}
	response.Body = &limitedBody{
		ReadCloser: response.Body,
		Reader:     io.LimitReader(response.Body, t.limit+1),
	}
	return response, nil
}

完整实现还要在读到 limit+1 时返回明确“响应过大”,并保证 Close 传递。错误 body 只保留有界、脱敏片段。Resty 默认管理响应 body 关闭;启用“不解析响应”等高级模式时,所有权可能交给调用者,必须按该 API 文档显式关闭,否则连接无法复用。

15. 使用 httptest 做协议与故障测试

测试不访问真实外部服务。httptest.Server 能验证方法、路径、header、JSON、重试次数和取消;自定义 RoundTripper 适合精确注入连接错误。测试结束关闭 Server 和空闲连接。

func TestGetRetries503(t *testing.T) {
	var attempts atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if attempts.Add(1) < 3 {
			http.Error(w, "busy", http.StatusServiceUnavailable)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"id":"a-42"}`)
	}))
	defer server.Close()

	client := resty.New().SetBaseURL(server.URL).SetRetryCount(2)
	// 添加仅对 503 生效的条件,再断言结果和 attempts == 3。
}

至少覆盖:正常 JSON、错误 JSON、非 JSON、超大 body、连接重置、响应头超时、context 取消、429 Retry-After、POST 不重试、幂等键保持、重定向到非信任 host、token 刷新并发和 hook error。竞态测试能发现运行期修改 Client 与共享结果指针。

16. 诊断连接与超时问题

大量连接/端口增长时,检查是否每请求创建 Client/Transport、是否高级模式忘记关闭 body、连接池是否太小、服务端是否发送 Connection: close。用 httptrace.ClientTrace 采样观察 DNS、获取连接、建连、TLS 和首字节,不要为每请求输出高量明细。

大量 deadline exceeded 时先确认来源:父 context、Client.Timeout、Dial、TLS、ResponseHeaderTimeout、重试退避还是读取 body。队列等待也消耗 deadline。错误发生后检查 attempts,避免最终超时掩盖前两个明确 503。

go test ./...
go test -race ./...
go test -bench=Client -benchmem ./...
go vet ./...
GODEBUG=http2debug=1 go test -run TestSpecificHTTP2Case

http2debug 输出量大且可能包含敏感元数据,只用于隔离环境。生产诊断优先指标、trace、连接池状态和 pprof 阻塞/goroutine profile。

17. 部署、容量和优雅关闭

每个下游分别设置连接上限、超时、重试和熔断策略,参数来自真实延迟与容量。部署时验证代理、DNS、CA bundle、HTTP/2、mTLS、最大响应和下游限流。扩容本服务会按实例数放大连接与重试,连接池不是只看单实例。

关闭时先停止接收新流量,等待在途请求受各自 context 完成,再关闭空闲连接。Transport 不负责取消应用自行创建的后台任务。容器终止宽限期必须覆盖请求预算和清理时间,但不应靠无限等待掩盖泄漏。

配置应区分总预算与阶段预算:

article_client:
  base_url: https://article-api.internal
  timeout: 2s
  connect_timeout: 300ms
  response_header_timeout: 1s
  max_conns_per_host: 50
  retry_max_attempts: 3
  max_response_bytes: 1048576

18. Resty、net/http 与生成客户端如何选

少量端点可直接用 net/http;多个 JSON 端点需要统一认证、解码、hook 和有界重试时选 Resty;已有 OpenAPI 契约时优先生成客户端。无论选谁,外层都暴露 GetArticle(ctx, id) 这类领域方法,不向业务泄漏底层 Client。

19. 可运行的有界客户端示例

下面实现只重试幂等 GET 的 503,复用 Transport,传递 context,并把远端状态映射成类型错误。测试可把 baseURL 指向 httptest.Server,无需外部网络。

package articleclient

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"time"

	"github.com/go-resty/resty/v2"
)

type Article struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type Client struct {
	http      *resty.Client
	transport *http.Transport
}

func New(baseURL string) *Client {
	transport := http.DefaultTransport.(*http.Transport).Clone()
	transport.MaxIdleConnsPerHost = 10
	transport.ResponseHeaderTimeout = time.Second

	client := resty.NewWithClient(&http.Client{
		Transport: transport,
		Timeout:   2 * time.Second,
	}).
		SetBaseURL(baseURL).
		SetRetryCount(2).
		SetRetryWaitTime(20 * time.Millisecond)
	client.AddRetryCondition(func(response *resty.Response, err error) bool {
		if err != nil || response == nil {
			return false
		}
		return response.Request.Method == http.MethodGet &&
			response.StatusCode() == http.StatusServiceUnavailable &&
			response.Request.Context().Err() == nil
	})
	return &Client{http: client, transport: transport}
}

func (c *Client) Close() {
	c.transport.CloseIdleConnections()
}

func (c *Client) GetArticle(ctx context.Context, id string) (Article, error) {
	var article Article
	response, err := c.http.R().
		SetContext(ctx).
		SetPathParam("id", id).
		SetResult(&article).
		Get("/v1/articles/{id}")
	if err != nil {
		return Article{}, fmt.Errorf("get article: %w", err)
	}
	if response.StatusCode() == http.StatusNotFound {
		return Article{}, fmt.Errorf("article %q: %w", id, ErrNotFound)
	}
	if response.IsError() {
		return Article{}, fmt.Errorf("article service status %d", response.StatusCode())
	}
	return article, nil
}

var ErrNotFound = errors.New("article not found")

示例还需加入 CA、认证和响应大小限制。它不自动重试传输错误,因为能否安全重放取决于请求阶段和方法语义。Resty 集中重复协议代码,可靠性仍来自所有权、预算、幂等性和可验证的故障行为。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。