Go HTTP 客户端入门:请求、超时、重试和 JSON API 调用

本文讲解 Go 标准库 HTTP 客户端的请求构造、响应读取、JSON 编解码、超时、重试和错误处理,适合刚开始对接外部 API 的学习者。

调用外部 API 是后端程序的日常

很多 Go 服务不只是接收请求,也会调用别的服务:支付网关、短信平台、内部用户中心、搜索服务、AI 接口、Webhook 地址。HTTP 客户端代码看似简单,一个 http.Get 就能拿到响应,但真实项目里需要考虑超时、状态码、JSON 解析、错误消息、重试和日志。

初学者最容易写出的问题是:没有超时、忘记关闭响应体、不检查状态码、把所有非 200 都当成同一种错误、读取大响应时不设边界。这些问题短期可能不明显,一旦外部服务变慢或返回异常,就会拖垮自己的程序。

Go 标准库的 net/http 已经足够写出可靠的 API 客户端。我们会从最小 GET 请求开始,逐步整理成一个小客户端类型。

最小 GET 请求

最简单的写法:

resp, err := http.Get("https://example.com")
if err != nil {
	return err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
	return err
}

fmt.Println(string(body))

这段代码有两个必须记住的点。第一,err 只表示请求过程出错,比如 DNS 失败、连接失败、超时等;如果服务器返回 500,err 仍然可能是 nil。第二,响应体必须关闭,否则连接资源无法复用或释放。

所以真实代码要检查状态码:

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
	return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}

很多 API 会在错误响应里返回 JSON 错误信息。你可以读取一小段响应体放进错误里,但不要无限读取很大的错误响应。

使用带超时的 Client

不要在服务代码里长期使用没有超时的默认请求。创建客户端:

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

调用:

resp, err := client.Get("https://example.com")

Timeout 是整个请求的上限,包括连接、重定向和读取响应体。对入门项目来说,这是最简单有效的保护。

如果你需要跟随请求 context 取消,可以使用 NewRequestWithContext

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
	return err
}

resp, err := client.Do(req)

服务端 handler 中应该传入 r.Context(),这样用户取消请求时,外部调用也能尽早停止。

调用 JSON API

定义响应结构体:

type UserProfile struct {
	ID    int64  `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

读取并解码:

func fetchUser(ctx context.Context, client *http.Client, baseURL string, id int64) (UserProfile, error) {
	url := fmt.Sprintf("%s/users/%d", strings.TrimRight(baseURL, "/"), id)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return UserProfile{}, fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Accept", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return UserProfile{}, fmt.Errorf("send request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNotFound {
		return UserProfile{}, fmt.Errorf("user not found")
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return UserProfile{}, fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}

	var profile UserProfile
	if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
		return UserProfile{}, fmt.Errorf("decode response: %w", err)
	}

	return profile, nil
}

这里没有先 io.ReadAlljson.Unmarshal,而是直接用 json.Decoder 从响应体解码。对于 JSON API,这是很常见的写法。

POST JSON 请求

请求结构体:

type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

发送:

func createUser(ctx context.Context, client *http.Client, baseURL string, input CreateUserRequest) (UserProfile, error) {
	var body bytes.Buffer
	if err := json.NewEncoder(&body).Encode(input); err != nil {
		return UserProfile{}, fmt.Errorf("encode request: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/users", &body)
	if err != nil {
		return UserProfile{}, fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return UserProfile{}, fmt.Errorf("send request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusCreated {
		return UserProfile{}, fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}

	var profile UserProfile
	if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
		return UserProfile{}, fmt.Errorf("decode response: %w", err)
	}
	return profile, nil
}

bytes.Buffer 实现了 io.Reader,可以作为请求体。发送 JSON 时记得设置 Content-Type

封装成客户端类型

如果同一个 API 会调用多次,封装类型更清楚:

type APIClient struct {
	baseURL string
	client  *http.Client
}

func NewAPIClient(baseURL string) *APIClient {
	return &APIClient{
		baseURL: strings.TrimRight(baseURL, "/"),
		client: &http.Client{
			Timeout: 5 * time.Second,
		},
	}
}

func (c *APIClient) User(ctx context.Context, id int64) (UserProfile, error) {
	url := fmt.Sprintf("%s/users/%d", c.baseURL, id)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return UserProfile{}, err
	}
	req.Header.Set("Accept", "application/json")

	resp, err := c.client.Do(req)
	if err != nil {
		return UserProfile{}, fmt.Errorf("get user: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return UserProfile{}, fmt.Errorf("get user status %d", resp.StatusCode)
	}

	var profile UserProfile
	if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
		return UserProfile{}, fmt.Errorf("decode user: %w", err)
	}
	return profile, nil
}

这样调用处很干净:

api := NewAPIClient("https://api.example.com")
user, err := api.User(ctx, 1001)

封装不是为了隐藏所有细节,而是把重复的 baseURL、client、header 和错误上下文集中起来。

重试要谨慎

外部请求失败时,重试有时有用,但不能无脑重试。GET 这类幂等请求相对安全,创建订单、扣款、发短信这类操作如果没有幂等键,重试可能造成重复副作用。

一个简单 GET 重试:

func doWithRetry(ctx context.Context, attempts int, fn func() error) error {
	var lastErr error
	for i := 0; i < attempts; i++ {
		if err := fn(); err != nil {
			lastErr = err
			select {
			case <-time.After(time.Duration(i+1) * 200 * time.Millisecond):
			case <-ctx.Done():
				return ctx.Err()
			}
			continue
		}
		return nil
	}
	return lastErr
}

真实项目里还要判断哪些错误可重试,比如网络临时错误、502、503、504;哪些不该重试,比如 400、401、403。重试也应该有上限和退避等待,避免外部服务已经故障时被你打得更严重。

小结

Go HTTP 客户端入门要抓住几个关键点:创建带超时的 http.Client,用 NewRequestWithContext 绑定取消信号,发送后一定关闭响应体,检查状态码,再解码 JSON。错误消息要带上请求动作,让排查时知道在哪一步失败。

封装客户端类型可以减少重复,也能把 baseURL、超时、header 和公共错误处理集中起来。重试要谨慎,只对适合重试的请求使用,并且设置上限。

对接外部 API 时,稳定性比“能请求成功一次”更重要。只要从一开始把超时、状态码和错误处理写清楚,你的 Go 服务就不会轻易被一个慢接口拖住。

继续阅读

探索更多技术文章

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

全部文章 返回首页