调用外部 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.ReadAll 再 json.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 服务就不会轻易被一个慢接口拖住。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。