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

Go http.Client 重试 POST 时 Body 变空:GetBody 与请求体复用的正确姿势

来源:17golang原创

时间:2026-07-24 14:08:19 488浏览 收藏

线上给支付接口补重试逻辑的时候,很容易碰到一种很反常的日志现象:第一次 POST 请求返回 503,第二次请求确实也发出去了,但服务端记录的请求体长度直接变成了 0。Go 代码看起来只是把同一个 *http.Request 再传给客户端调用,问题却在请求体内容已经被读完之后才突然暴露出来。

POST 重试之前必须重新提供一个全新的可读 Body 实例。用 http.NewRequest 配合 bytes.Readerstrings.Readerbytes.Buffer 构造请求时,Go 标准库通常会自动设置 GetBody;如果 Body 来自外部流或者自定义读取器,就要自己提前保存原始字节并实现重新打开的逻辑。

要点速览
  • 请求体本质是流,第一次发送完读指针已经移动到末尾,直接复用 Request 实例不等于自动复用 Body 里的内容。
  • GetBody 会返回一份全新的读取器,适合在每次重试之前重新挂载到请求上。
  • 重试只适合明确的临时失败场景,必须提前配置好次数、退避策略和请求幂等边界。
  • 验收的时候同时记录请求尝试次数、Body 长度和服务端收到的内容摘要,才能确认第二次请求真的带上了完整数据。

先复现第二次请求 Body 为空的现场

下面我们用一个本地 HTTP 服务模拟“第一次返回 503,第二次返回 200”的异常场景。服务端不需要保存完整业务数据,只需要打印每次收到的请求体长度和内容摘要,就足够清晰看清请求体在两次发送之间到底发生了什么变化。

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
)

func main() {
    attempt := 0
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        attempt++
        body, _ := io.ReadAll(r.Body)
        fmt.Printf("attempt=%d body=%q length=%d\n", attempt, body, len(body))
        if attempt == 1 {
            http.Error(w, "temporary unavailable", http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    }))
    defer server.Close()

    payload := []byte(`{"order_id":"A1024","amount":1999}`)
    req, _ := http.NewRequest(http.MethodPost, server.URL, bytes.NewReader(payload))

    client := server.Client()
    for i := 0; i 

运行之后常见的输出是第一次请求体长度完全正常,第二次请求体长度直接为 0。背后的原因非常直接:client.Do 会从 req.Body 里逐字节读取内容,第一次读取完成之后,原来的读取器已经走到了数据末尾。Request 结构本身还完好,但 Body 里的字节内容不会自动倒带重置。

Go POST 重试时第一次请求读完 Body、第二次请求进入空 Body 的资源预算图

把请求体和重试次数拆成两个独立问题

动手修复之前别急着直接把循环改成更多重试次数。重试逻辑至少要划清两条边界:一条是“这一轮请求要发送什么字节内容”,另一条是“什么样的响应允许发起重试”。前者解决数据是否完整的问题,后者决定会不会出现重复扣款或者重复创建订单的业务故障。

检查项推荐判断规则出问题后排查点
Body 来源是否支持每轮都重新打开读取GetBody 是否为空
响应状态只对明确标记为临时错误的响应重试503、429 和业务主动返回的错误要区分开
业务副作用服务端是否支持通过幂等键去重订单号或者 Idempotency-Key
资源上限重试次数、超时时间、退避间隔都要设置上限日志里的 attempt 字段和单次请求耗时

尤其是 POST 请求场景:就算客户端能重新发送完整 Body,也不代表业务上适合直接重试。支付、库存扣减、发券这类操作应该让服务端通过业务幂等键做去重处理,不能只依赖 HTTP 状态码就随便重试。

用 GetBody 在每次重试前重新挂载读取器

使用标准库构造请求的时候,bytes.Readerstrings.Readerbytes.Buffer 这几类常见输入场景,会让 http.NewRequest 自动记录一份可以重新创建 Body 的函数。实际发送前可以先检查这个字段,就能为每一次重试尝试拿到全新的读取器。

func postWithRetry(client *http.Client, url string, payload []byte, maxTry int) (*http.Response, error) {
    req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", "order-A1024")

    for attempt := 1; attempt  1 {
            if req.GetBody == nil {
                return nil, fmt.Errorf("request body cannot be reopened")
            }
            fresh, err := req.GetBody()
            if err != nil {
                return nil, fmt.Errorf("reopen request body: %w", err)
            }
            req.Body = fresh
        }

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        if resp.StatusCode != http.StatusServiceUnavailable && resp.StatusCode != http.StatusTooManyRequests {
            return resp, nil
        }
        resp.Body.Close()
    }
    return nil, fmt.Errorf("temporary failure after %d attempts", maxTry)
}

这段代码有两个很容易被忽略的细节点。第一,只有第二轮及之后的重试才需要重新赋值 Body,第一次发送仍然直接使用 NewRequest 创建的原始 Body。第二,上一次请求的响应体在进入下一轮重试之前必须关闭,否则底层 TCP 连接无法及时回收,重试次数多了很容易先撞上客户端连接资源上限。

Go http.Client 重试前调用 GetBody 重新打开请求体并让第二次 POST 完整到达服务端

自定义 Body 时保存原始字节,不要指望客户端自动复制

如果请求体来自本地文件、压缩流或者加密管道,GetBody 往往是空的。更稳妥的做法是在确认允许重试的环节,先保存一份体积在可控范围内的原始字节,或者把“重新打开文件”的动作封装成自定义工厂方法。下面这个小函数非常适合 JSON 这类体积可控的请求场景。

func newJSONRequest(method, url string, payload []byte) (*http.Request, error) {
    copied := append([]byte(nil), payload...)
    req, err := http.NewRequest(method, url, bytes.NewReader(copied))
    if err != nil {
        return nil, err
    }
    return req, nil
}

不要为了实现重试逻辑就把无限大的上传内容全部塞进内存里。大文件上传可以在每轮重试的时候重新打开文件、把读取指针定位到开头,确认文件没有被并发改写之后再发起发送;流式传输的数据则要明确标记为不可重试,或者改成服务端支持断点续传与幂等校验的协议。

用日志和测试确认第二次真的带上了 Body

最终验收环节不要只看接口返回 200 就觉得没问题。服务端记录的请求次数、每次的请求体长度和幂等键,才是判断重试逻辑是否正确的核心依据。一个最小范围的校验可以固定四个断言点:

  • 第 1 次返回 503,第 2 次返回 200。
  • 两次请求的 Body 长度完全相同,内容摘要也保持一致。
  • 两次请求携带完全相同的幂等键。
  • 超过最大重试次数之后,所有历史响应体都已经关闭,函数返回明确的错误信息。

如果第二次请求长度仍然是 0,优先打印 req.GetBody == nil、每轮发送前的 Body 长度,以及服务端实际收到的长度。这样能快速区分“没有重新挂载 Body”“重新打开读取器失败”和“服务端读取逻辑分支出错”这几类问题,不用一上来就盲目怀疑是网络出了问题。

常见问题

为什么把同一个 Request 实例再传给 client.Do 不行?

因为 Body 是一次性读取器,第一次发送的时候就会把内容全部消费完。Request 本身不会自动保存一份可以反复回放的完整数据副本。

GetBody 一定会自动存在吗?

不一定。标准库对几种常见的内存读取器场景会自动设置这个字段,但自定义读取器、文件流和管道类场景,通常需要开发者自己提供重新打开的逻辑。

所有 500 或 503 状态的请求都应该重试吗?

不应该。先确认失败是不是临时出现的、请求本身是否具备幂等语义,再限制重试次数和总耗时;业务逻辑层面返回的错误不该靠重复请求来解决。

小结:重试前先问一句 Body 能否重开

Go 的 HTTP 重试出问题,通常都不是循环逻辑写错了,而是误把一次性读取器当成了可以反复读取的静态数据。把原始字节备份、GetBody 配置、幂等键校验、最大重试次数和验证日志整合到同一个设计里,才能既保证第二次请求的内容完整,也避免把临时故障演变成重复的业务操作。

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