Go 的网络编程依赖 net 包和 io 包提供的统一接口,但网络错误的来源远比普通函数调用复杂。一次 net.Conn.Read 可能因为对端关闭返回 io.EOF,可能因为超时返回 net.Error,也可能因为系统调用被打断返回 EINTR。更麻烦的是,这些错误会随着 fmt.Errorf 包装、goroutine 传递逐渐丢失原始语义。如果处理不当,轻则日志噪声过大,重则重试风暴或资源泄漏。因此,网络编程中的错误处理需要从错误分类、传播链路、重试策略、日志记录几个层面建立明确规范。

一、先识别错误类型,避免把临时故障当永久错误
网络错误和普通文件错误最大的区别在于,它经常受到外部环境影响。对端重启、路由抖动、DNS 缓存失效都会产生不同类型的错误。Go 标准库通过 net.Error 接口把超时信息暴露出来:
type Error interface {
error
Timeout() bool
Temporary() bool
}
在实际项目中,建议用 errors.As 取出 net.Error,优先判断 Timeout()。不要继续依赖已经弃用的 Temporary() 方法,因为很多网络库不会准确实现它,Go 官方也明确表示该方法语义模糊。连接拒绝、连接被重置这类错误通常属于临时故障,但证书校验失败、协议不匹配则必须立刻返回,不能盲目重试。
下面这段代码展示了从 TCP 连接读取数据时如何区分超时错误:
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return fmt.Errorf("read from %s timeout: %w", conn.RemoteAddr(), err)
}
对于系统调用错误,可以借助 errors.Is 与 syscall 包中的哨兵错误进行比较。例如 syscall.ECONNRESET 表示连接被对端重置,syscall.ECONNREFUSED 表示目标端口未监听。把它们和超时错误分开处理,上层才知道什么时候应该快速返回,什么时候可以延迟重试。
二、显式控制超时,不能让底层默认值决定请求寿命
很多网络请求卡死的根本原因是超时设置缺失。Go 的 http.Client 默认没有全局超时,如果服务端只建立连接但不返回响应,客户端会一直阻塞。使用 http.Client 做外部调用时,至少应该设置 Timeout,同时结合 context.WithTimeout 对单次请求进行精确控制。
func doHTTPRequest(ctx context.Context, url string) error {
client := &http.Client{
Timeout: 10 * time.Second,
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
if os.IsTimeout(err) {
return fmt.Errorf("request timeout: %w", err)
}
return err
}
defer resp.Body.Close()
return nil
}
超时错误不一定只来自 context.DeadlineExceeded。底层网络库通常会返回一个实现了 net.Error 且 Timeout() 为 true 的错误,os.IsTimeout 可以统一识别这两种情况。判断时不要用字符串匹配错误信息,因为 Go 的错误文本并不稳定。
对于长连接服务,建议分别设置连接超时、读超时和写超时。客户端可以用 net.Dialer.Timeout 控制建立连接的时间,服务端可以用 SetReadDeadline 防止慢客户端占住连接。超时错误发生后,连接通常已经不可复用,应当主动关闭并释放资源,而不是继续写入数据。
三、把 io.EOF 和连接关闭纳入正常流程
io.EOF 在 Go 中不是错误,而是流结束信号。网络编程中读取数据时,如果对端正常关闭写端,Read 会返回 0, io.EOF。很多开发者看到错误就记录堆栈,导致每次客户端断开都产生一条告警,这是不必要的噪声。
buf := make([]byte, 4096)
for {
n, readErr := conn.Read(buf)
if n > 0 {
handle(buf[:n])
}
if readErr != nil {
if errors.Is(readErr, io.EOF) {
return nil
}
return fmt.Errorf("read from connection: %w", readErr)
}
}
在 HTTP 服务端,读取请求体时遇到 io.EOF 也属于正常结束。但在处理连接复用时要注意,客户端可能在你写入响应之前就关闭连接,这时写入会返回 broken pipe 或 connection reset。这类错误无法完全避免,一般记录为低级别日志即可,不需要触发告警。
对于需要长连接推送的服务,还应该区分“对端主动关闭”和“本端主动关闭”。本端主动关闭前应调用 CloseWrite 或 Close 发送 FIN 包,等待对端确认后再完全释放,避免处于半关闭状态造成文件描述符泄漏。
四、包装错误时保留上下文,日志记录不要重复
错误从底层网络库传到业务层,要经过多个函数。每一层如果只返回 err,最终日志里可能只有一个 connection refused,无法定位是哪个地址、哪个操作失败。推荐的做法是用 fmt.Errorf 配合 %w 包装底层错误,同时补充当前层的上下文。
func dialTCP(addr string) (net.Conn, error) {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return nil, fmt.Errorf("dial tcp %s: %w", addr, err)
}
return conn, nil
}
包装错误时不要丢失原始错误,也不要只返回格式化后的字符串。使用 %w 可以让 errors.Is 和 errors.As 继续穿透错误链,上层仍然能够识别 net.Error 或 syscall 错误。如果中间层需要记录日志,应该记录完整错误链,但要避免每一层都打印一次,否则同一条错误会产生大量重复日志。
结构化日志在排查网络问题时非常有效。记录错误时建议携带 operation、remote、component 等字段,例如:
slog.Error("network request failed",
"operation", "read",
"remote", conn.RemoteAddr().String(),
"error", err,
)
最后,可以为业务层定义自己的哨兵错误,例如 ErrMaxRetries 表示重试次数耗尽,ErrCircuitOpen 表示熔断器打开。网络层返回的底层错误经过包装后,业务层使用 errors.Is 判断这些哨兵错误来决定返回 503、504 还是直接降级。这样整个错误处理链路既保留了网络细节,又能让上层做出清晰决策。
Golang网络编程Go错误处理网络错误处理修改时间:2026-09-21 00:05:04