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

http.Client 重试可重放请求体的设计要点

来源:17golang原创

时间:2026-11-12 16:22:57 226浏览 收藏

如果一个 POST 请求第一次发送失败后直接再次调用 client.Do(req),最容易遇到的现象是:第二次请求的 body 为空,或者服务端收到的内容不完整。原因并不在 http.Client “忘了重试”,而在于 io.Reader 通常是一次性游标,第一次读取后已经走到了末尾。

可靠做法是把“重试”拆成两个条件:第一,请求体必须能重新生成;第二,当前业务操作允许重复提交。对常见的 bytes.Reader、strings.Reader 和 bytes.Buffer,http.NewRequest 会自动设置 GetBody;对文件、流式编码器等自定义输入,则应显式提供生成副本的函数。

为什么请求体重试需要 GetBody

Request.Body 是真正交给传输层读取的流,发送后不能假设它仍然指向开头。Request.GetBody 的职责是返回一个新的 io.ReadCloser,它应当包含与原请求相同的字节。这个函数只对客户端请求有意义,服务端收到的请求不会依赖它。

Go HTTP 客户端通过 Request.GetBody 生成请求体副本并重试的流程说明图
图1:Request.GetBody 生成新请求体副本的静态说明图,不是运行截图。

标准库文档还说明,307 和 308 重定向需要保留原方法和 body;只有定义了 GetBody,客户端才有条件重新发送。也就是说,GetBody 不只是给自定义重试封装准备的,它也是某些重定向场景能否保留请求体的基础。

先用标准 Reader 让 NewRequest 自动准备

如果请求数据已经在内存中,优先交给 http.NewRequest 创建请求。对标准库认识的几种 Reader,它会设置精确的 ContentLength,并填充 GetBody。下面的例子只演示请求构造,不代表向真实服务发送。

package main

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

func main() {
    payload := []byte(`{"name":"demo","count":2}`)

    // bytes.Reader 保留了原始字节,NewRequest 可以据此生成 GetBody。
    req, err := http.NewRequest(http.MethodPost, "https://api.example.test/items", bytes.NewReader(payload))
    if err != nil {
        panic(err)
    }
    req.Header.Set("Content-Type", "application/json")

    // GetBody 为每次重放返回一个新的读取器,不能把第一次的 Body 当副本。
    replay, err := req.GetBody()
    if err != nil {
        panic(err)
    }
    defer replay.Close()
    fmt.Println("content-length:", req.ContentLength)
    fmt.Println("get-body-ready:", replay != nil)
}

这里的关键不是保存一个已经读过的 Body,而是保存可重新生成 body 的数据源。若数据来自字符串,可以使用 strings.NewReader;若来自可变的 bytes.Buffer,应在创建请求前确定其内容,避免后续修改同一缓冲区导致重试数据与第一次不同。

自定义请求体时显式实现 GetBody

当 body 来自文件、压缩器或自定义编码器时,标准库未必知道怎样从头生成它。此时可以把“生成新读取器”的逻辑抽成闭包,并同时设置最初的 Body 和 GetBody。下面用一份固定字节切片模拟可重复的数据源。

package retrybody

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

// NewJSONRequest 使用同一份不可变字节数据构造可重放请求。
func NewJSONRequest(method, url string, data []byte) (*http.Request, error) {
    // 复制输入,避免调用者之后修改 data 影响第一次发送和重试。
    snapshot := append([]byte(nil), data...)
    req, err := http.NewRequest(method, url, bytes.NewReader(snapshot))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")

    // 显式写出 GetBody,便于换成文件或自定义编码器时保持同一契约。
    req.GetBody = func() (io.ReadCloser, error) {
        return io.NopCloser(bytes.NewReader(snapshot)), nil
    }
    return req, nil
}

如果数据来自文件,GetBody 可以在每次调用时重新 os.Open;如果数据来自压缩或签名流程,则必须保证每次生成的字节和必要的请求头规则一致。不要把已经消耗过的网络流、管道或实时编码器简单包进 GetBody,它们通常无法提供相同内容。

Go 请求体可重放数据源与一次性 io.Reader 的边界关系说明图
图2:可重放数据源与一次性流的边界静态说明图,不是运行截图。

重试封装要同时处理响应和请求生命周期

一个小型封装可以在每次尝试前调用 GetBody,遇到可重试条件时先读完并关闭失败响应,再等待一小段时间。对于首个请求,直接使用已创建的 Body;后续尝试则替换为新的副本。

package retrybody

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "time"
)

// DoWithRetry 只对调用方明确允许重复提交的请求使用。
func DoWithRetry(ctx context.Context, client *http.Client, req *http.Request, maxAttempts int) (*http.Response, error) {
    if maxAttempts  0 {
            // 每次重试都重新生成 body,不能重复使用已读到末尾的 Body。
            if req.GetBody == nil {
                return nil, fmt.Errorf("request body is not replayable")
            }
            body, err := req.GetBody()
            if err != nil {
                return nil, fmt.Errorf("recreate request body: %w", err)
            }
            req.Body = body
        }

        resp, err := client.Do(req)
        if err == nil && resp.StatusCode 

这个示例把 5xx 当作可重试信号只是为了展示流程,生产代码还应结合接口契约、超时错误、Retry-After 和幂等键来决定。POST 是否可重试,不能只看 HTTP 方法名;如果服务端可能已经完成写入但响应丢失,重复提交可能创建两条数据。

运行检查:验证每次 body 是否一致

可以用本地测试服务器记录每次收到的 body,重点观察两件事:第二次请求是否拿到完整内容,以及失败响应是否被关闭。下面的检查逻辑使用 httptest,只用于测试代码路径。

func TestReplayBody(t *testing.T) {
    var received [][]byte
    attempts := 0
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 每次都完整读取,记录服务端实际收到的请求体。
        body, err := io.ReadAll(r.Body)
        if err != nil {
            t.Fatalf("read body: %v", err)
        }
        received = append(received, body)
        attempts++
        if attempts == 1 {
            http.Error(w, "temporary failure", http.StatusBadGateway)
            return
        }
        w.WriteHeader(http.StatusNoContent)
    }))
    defer server.Close()

    // 测试应断言两次内容一致,再决定是否允许该请求在业务中重试。
    _ = received
}

测试断言可以比较 received[0] 和 received[1],也可以检查请求次数不超过上限。不要把服务端返回 204 当作唯一成功标准;真实业务还要检查响应码、响应体协议以及服务端的幂等处理。

一张表记住设计边界

场景请求体策略能否直接重试
bytes.Reader / strings.Reader / bytes.Buffer交给 NewRequest 自动生成 GetBody具备重放条件,但仍要判断业务幂等性
文件或固定字节快照GetBody 中重新打开或重新创建 Reader可以在资源和并发边界明确时重试
实时网络流、管道、一次性编码器先落盘或改成可重复生成的编码流程没有副本前不要重试
可能已成功写入的 POST配合幂等键、业务去重或服务端协议不能只因为收到 5xx 就盲目重试

结论与常见问题

请求体重试的核心是“每次发送前都有一个新的 Body”,而不是重复调用同一个已经读过的 Reader。优先使用 NewRequest 识别的标准 Reader;自定义数据源则显式实现 GetBody。最后,再把重试次数、退避、响应关闭和业务幂等性放进同一个设计里。

GetBody 设置了就一定会自动重试吗?

不会。GetBody 主要提供重新生成请求体的能力,Client.Do 不会因为它存在就替你执行任意网络错误重试。重试策略仍由应用或更上层客户端负责。

为什么不能把第一次的 req.Body 重新赋值给自己?

Body 通常已经被读取到末尾,重新引用同一个对象不会回到开头;即使某些类型支持 Seek,也不应把传输层和并发访问的假设隐藏在通用重试逻辑里。

307 和 308 重定向为什么更依赖 GetBody?

这两类重定向保留原方法和请求体,客户端必须能生成一份新的 body 才能继续发送。没有 GetBody 时,安全行为通常是不要重放带 body 的请求。

重试前为什么要关闭失败响应?

响应体属于上一次请求的资源。读完并关闭它有助于 Transport 复用连接,也能避免重试循环积累未关闭的响应体。

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