在Go语言开发中,网络请求是最常见的外部依赖操作之一。无论是调用第三方API还是访问内部服务,错误处理直接决定了程序的健壮性。很多初学者容易把传输层错误和HTTP协议层的状态响应混为一谈,导致该重试的没重试,不该抛错的却直接崩溃。

一、Go中网络请求错误的来源分类
使用标准库net/http发起请求时,错误主要来源于两个层面。第一是传输层错误,这类错误由Client.Do、http.Get等函数以error形式返回,例如DNS解析失败、TCP连接被拒绝、TLS握手异常、请求超时等。第二是协议层响应,即服务器正常返回了HTTP报文,但状态码是4xx或5xx,此时error为nil,需要开发者自行检查Response.StatusCode。
理解这一点非常关键。不少人在写代码时只判断err != nil就认为请求彻底失败,却忽略了状态码错误的处理,使得服务端返回的验证失败或限流信息被无声忽略。下面通过一段基础代码展示正确的捕获结构。
package main
import (
"fmt"
"net/http"
)
func main() {
resp, err := http.Get("https://ipipp.com/api/test")
if err != nil {
// 传输层错误,如网络不可达、DNS失败
fmt.Println("传输错误:", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// 协议层错误,服务端已响应但状态异常
fmt.Println("业务错误,状态码:", resp.StatusCode)
return
}
fmt.Println("请求成功")
}
1.1 传输层错误的常见类型
在Linux或容器环境中,传输层错误常表现为connection refused、no such host、i/o timeout等。这些错误通常实现了net.Error接口,可通过类型断言判断是否超时或临时错误。例如net.Error的Timeout()方法可以区分是超时还是其他故障。
对于微服务架构,网络抖动难以避免,识别临时错误有助于决定是否重试。Go的errors.As配合net.Error可以写出更优雅的判断逻辑,而不是简单字符串匹配,后者在跨语言错误信息本地化时极易失效。
1.2 协议层状态的处理误区
部分开发者误以为只要err为nil就代表业务成功,于是对resp.StatusCode不闻不问。实际上,很多RESTful接口在参数错误时返回400,在鉴权失败时返回401,这些都属于正常的TCP通信结果。若忽略状态码,前端可能收到空数据却显示正常,埋下排查隐患。
建议将状态码判断封装为独立函数,例如isSuccess(code int),在团队内统一错误语义,避免不同开发者各自写if code == 200造成标准分裂。
二、使用Context控制超时与取消
Go 1.7之后,net/http全面支持context.Context。通过http.NewRequestWithContext可以精确控制单次请求的超时时间,而不依赖全局http.Client.Timeout。这样在批量请求时,每个请求可以有不同的截止时间。
当请求被Context取消时,Do方法会返回context.Canceled或context.DeadlineExceeded错误。这类错误应当被视为可控中断,而非系统异常,在日志中可降级为信息级别。
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func doRequest() error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://ipipp.com/api/slow", nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
// 可能是 context.DeadlineExceeded
if ctx.Err() == context.DeadlineExceeded {
fmt.Println("请求超时,可重试")
}
return err
}
defer resp.Body.Close()
fmt.Println("状态码:", resp.StatusCode)
return nil
}
2.1 客户端级别超时设置
除了Context,还可以在http.Client结构体中对连接、读写分别设置超时:Timeout是整个请求上限,Transport中的TLSHandshakeTimeout、ResponseHeaderTimeout等细化参数能防止某一阶段挂死。对于公网调用,建议同时设置Context超时与Client超时,形成双重保护。
需要注意的是,若只设置Client.Timeout而未传Context,请求无法被业务主动取消,比如在用户关闭页面时后端仍在傻等响应,浪费连接资源。
2.2 错误重试的基本策略
对于幂等GET请求,遇到net.Error超时或503状态码时可做有限次重试。重试间隔建议采用指数退避,避免对故障服务造成雪崩。下面示例展示简单的重试封装。
package main
import (
"fmt"
"net/http"
"time"
)
func getWithRetry(url string, retries int) (*http.Response, error) {
var resp *http.Response
var err error
for i := 0; i < retries; i++ {
resp, err = http.Get(url)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
time.Sleep(time.Duration(1<<uint(i)) * time.Second)
}
return nil, fmt.Errorf("重试失败: %v", err)
}
三、错误信息的结构化与日志
在生产环境中,原始error字符串不利于告警分类。推荐定义业务错误类型,将网络错误映射为内部错误码。例如ErrNetworkTimeout、ErrServiceUnavailable,上层只需判断错误类型即可决定降级方案。
同时,日志中应记录请求方法、URL、耗时与状态码,但不应记录响应体中的敏感字段。通过log.Printf配合结构化字段,可以让运维平台快速聚合“某接口超时率”等指标。
| 错误场景 | error是否为nil | 处理建议 |
|---|---|---|
| DNS解析失败 | 否 | 检查配置,快速失败 |
| 连接被拒绝 | 否 | 服务不可用,可重试 |
| HTTP 404 | 是 | 检查路径,业务处理 |
| HTTP 503 | 是 | 后端过载,退避重试 |
3.1 避免资源泄漏
无论请求成功或失败,只要resp非nil,就必须调用resp.Body.Close(),否则底层TCP连接无法复用,最终导致文件描述符耗尽。即使只读取状态码不读 body,也应关闭。使用defer是最省心的做法,但要注意在返回前已确知resp不为nil。
在错误分支中常有人忘记关闭,因此可以将关闭逻辑写在判断err == nil之后统一defer,或在每个返回点显式关闭,二者择一并保持团队一致。
3.2 统一中间件封装
中大型项目建议将请求客户端封装为带拦截器的结构,自动注入TraceID、统一超时、统一错误转换。这样业务代码只需关心StatusCode对应的领域错误,不需要反复写if err != nil样板逻辑,也降低新人出错概率。
封装时需注意不要让中间件吞掉错误,应原样或包装后向上传递,保证调用链末端仍能拿到根因,方便定位是自身代码还是依赖服务的问题。
四、小结
捕获Golang网络请求错误的核心在于分层:传输错误看error,协议错误看StatusCode。配合Context做超时取消,用退避重试应对临时故障,并以结构化错误提升可观测性。把这些实践固化到基础库,才能让每次外部调用都稳妥可控。