登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go httptrace.WroteRequest 返回错误时如何判断请求是否发出

来源:17golang原创

时间:2026-10-18 08:59:52 393浏览 收藏

用 Go 排查 HTTP 客户端偶发失败时,httptrace.ClientTrace.WroteRequest 很有价值,但它回答的只是一个窄问题:Transport 写请求及请求体这一阶段有没有遇到错误。WroteRequestInfo.Err 为 nil,只能说明写入阶段没有向 trace 报告错误;它不等于服务端已经收到请求,更不等于业务处理成功。

官方文档:https://pkg.go.dev/net/http/httptrace

判断“请求到底发生了什么”,要把 WroteRequest 与主流程返回的 RoundTrip 或 Client.Do 错误、响应头、GotConn 和 GotFirstResponseByte 放在同一条证据链里。WroteRequestInfo.Err 非空时可以确认写请求阶段失败,但仍不能仅凭它判断服务端是否看到了部分请求。

先区分 WroteRequest 回调与请求最终结果

ClientTrace 是客户端 Transport 暴露的观测钩子。WroteRequest 在请求和请求体写入结果可报告时被调用,参数里的 Err 是写请求阶段遇到的错误;在重试场景中,同一个逻辑请求可能出现多次回调。它不是服务端回执,也没有返回 HTTP 状态码。

证据可以判断不能单独判断
WroteRequestInfo.Err == nil当前一次写请求阶段没有报告写入错误服务端已收到、已执行业务或已提交事务
WroteRequestInfo.Err != nil写请求阶段遇到错误,可能只写出了一部分服务端完全没看到请求,或服务端一定没有副作用
GotFirstResponseByte 被调用客户端观测到响应的第一个字节响应内容代表业务成功
RoundTrip 返回响应Transport 返回了一个响应对象状态码一定是 2xx,或业务操作没有重复执行
WroteRequest 所在客户端写请求阶段与主流程结果的静态关系说明图
图1:WroteRequest 所在请求阶段的静态结构说明图,不是运行截图或网络抓包。

因此,标题里的“请求是否发出”最好拆成三个层次:客户端是否尝试写入、写入阶段是否报错、服务端是否收到并完成业务处理。第一层和第二层可以由 trace 辅助观察,第三层要依靠响应协议、服务端日志、业务幂等键或查询结果确认,不能由一个客户端回调越权证明。

读取 WroteRequestInfo.Err,并保留主流程错误

下面的示例把每次尝试的编号写入日志。注释里的“失败”只指写请求阶段失败;真正发起请求的代码仍然检查 client.Do 的返回值,并在响应存在时关闭响应体。

package main

import (
    "context"
    "fmt"
    "net/http"
    "net/http/httptrace"
)

func doRequest(ctx context.Context, client *http.Client, req *http.Request, attempt int) error {
    trace := &httptrace.ClientTrace{
        WroteRequest: func(info httptrace.WroteRequestInfo) {
            if info.Err != nil {
                // 中文说明:这里只记录写请求阶段错误,不推断服务端一定未收到数据。
                fmt.Printf("attempt=%d stage=wrote_request err=%v\n", attempt, info.Err)
                return
            }
            // 中文说明:nil 只表示这次写入阶段没有报告错误,不是业务成功凭证。
            fmt.Printf("attempt=%d stage=wrote_request err=nil\n", attempt)
        },
    }

    traced := req.WithContext(httptrace.WithClientTrace(ctx, trace))
    resp, err := client.Do(traced)
    if err != nil {
        // 中文说明:主流程错误可能来自连接、写入、读取或上下文取消,要保留它。
        return fmt.Errorf("client.Do attempt %d: %w", attempt, err)
    }
    defer resp.Body.Close() // 中文说明:响应体由本次请求负责关闭,便于连接复用。
    fmt.Printf("attempt=%d status=%s\n", attempt, resp.Status)
    return nil
}

这里有两个容易写错的地方。第一,不能在 WroteRequest 回调里直接把 nil 当成“请求成功”,因为响应读取和业务处理还没有发生。第二,不能丢掉 client.Do 的错误;只打印 trace 日志会让连接失败、写入失败、响应头超时和上下文取消混成同一种现象。

把连接、写入和响应阶段放在同一条证据链

单独观察 WroteRequest 往往不够。排障时可以同时记录连接是否复用、是否完成请求写入、是否收到响应首字节和主流程最终错误。这样能回答“失败发生在连接前、写入中,还是已经收到响应后”。

trace := &httptrace.ClientTrace{
    GotConn: func(info httptrace.GotConnInfo) {
        // 中文说明:记录连接是否复用,帮助区分连接建立问题和请求阶段问题。
        fmt.Printf("stage=got_conn reused=%t was_idle=%t\n", info.Reused, info.WasIdle)
    },
    WroteRequest: func(info httptrace.WroteRequestInfo) {
        // 中文说明:保留 Err 原值,避免把部分写出误判成完全未发送。
        fmt.Printf("stage=wrote_request err=%v\n", info.Err)
    },
    GotFirstResponseByte: func() {
        // 中文说明:首字节只说明客户端看到了响应开始,不代表业务状态成功。
        fmt.Println("stage=got_first_response_byte")
    },
}

建议为每个逻辑请求携带稳定的 request id,并为每次尝试增加 attempt 字段。不要只按日志出现顺序拼接事件:回调可能在请求完成前后由不同 goroutine 执行,且重试会让一个逻辑请求对应多个写入结果。

GotConn、WroteRequest、首字节、主流程错误与幂等重试之间的静态证据关系图
图2:HTTP 客户端 trace 证据链的静态关系说明图,不代表真实运行结果。

写入失败时,为什么不能立即安全重试

如果 WroteRequestInfo.Err 非空,常见反应是立刻再次发送。这个动作对查询类幂等请求通常更容易接受,但对创建订单、扣库存、发送通知等操作就可能重复产生副作用。写入错误并不能证明服务端完全没有收到字节:连接可能在服务端读取到部分请求后才断开,或者客户端在写完请求后、读取响应前遇到错误。

Go 的 Transport 会在满足条件时对某些幂等请求做底层重试,但业务层不能把这种行为扩展成“所有 POST 都能重发”。需要业务层重试时,至少先明确下面三项:

  1. 操作是否天然幂等,例如重复读取、按唯一键查询或带明确幂等键的写操作。
  2. 服务端是否支持幂等键,并能把相同键映射到同一次业务结果。
  3. 错误发生在连接获取、请求写入、响应读取还是响应已返回之后;不同阶段的重复风险不同。
func shouldRetry(method string, err error, idempotencyKey string) bool {
    if err == nil {
        return false
    }
    // 中文说明:只把明确的幂等方法作为默认安全候选,业务写操作需要额外协议。
    switch method {
    case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
        return true
    default:
        // 中文说明:有幂等键也不能跳过服务端契约确认,这里仅返回“需要业务判断”。
        return idempotencyKey != "" && serverSupportsIdempotency()
    }
}

这里的 serverSupportsIdempotency 只是业务策略占位,不是标准库函数。真正实现时要让服务端和客户端共同约定键的生命周期、冲突返回和结果保存方式。

一份可落地的排障清单

  1. 为逻辑请求记录 request id,并为每次重试记录 attempt。
  2. 同时记录 GotConn、WroteRequestInfo.Err、GotFirstResponseByte 和 Client.Do 的最终错误。
  3. 看到 WroteRequestInfo.Err == nil 时,只把它标记为“写入阶段未报错”,不要写成“服务端处理成功”。
  4. 看到非空错误时,保留原始错误和请求方法;对非幂等操作先确认服务端是否可能已经执行,再决定是否重试。
  5. 正式环境不要长期打开高噪声 trace;用短时采样、采样率和脱敏字段控制日志量。

常见疑问

WroteRequest 的 Err 为 nil,能说明请求已经发到网卡吗?

不能。它说明 Transport 的写请求阶段没有报告错误,但网络发送、服务端读取、代理转发和业务处理仍可能在之后失败。

WroteRequest 的 Err 非空,服务端一定没有收到请求吗?

不一定。写入可能部分完成,服务端或中间代理可能已经看到了一部分数据。因此非幂等操作不能只凭这个错误自动重试。

为什么还要记录 GotFirstResponseByte?

它能把“请求已经进入响应阶段”和“还停留在连接或写入阶段”区分开,但它仍然只是响应开始的客户端观测,不能替代状态码和业务结果判断。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>