From 067a4644c71eb832112d0e28b01029e5ea280b00 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Tue, 6 Jan 2026 20:41:44 -0300 Subject: [PATCH 1/3] Fix request body handling in DoExponentialBackoff Previously, DoExponentialBackoff relied on Seek to reset request bodies between retries. This approach failed when bodies didn't implement Seek or when req.GetBody wasn't set (e.g., manually wrapped io.NopCloser), causing "ContentLength=N with Body length 0" errors on retry attempts. The issue occurred because req.Clone() reuses the exhausted body reader without resetting it, leading to empty bodies on subsequent attempts while ContentLength remained set from the original request. Now read the entire request body upfront and create fresh io.Readers for each retry attempt. This ensures: - Body is always available for all retry attempts - ContentLength is correctly set to match the actual body - Works reliably with all io.Reader types, not just seekable ones Added test case with io.NopCloser-wrapped bytes.Buffer that validates both body content and ContentLength matching across retry attempts. --- httputilx/httputilx.go | 26 ++++++++++++++----- httputilx/httputilx_test.go | 52 ++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/httputilx/httputilx.go b/httputilx/httputilx.go index c135bde..8aa79f3 100644 --- a/httputilx/httputilx.go +++ b/httputilx/httputilx.go @@ -232,15 +232,27 @@ func DoExponentialBackoff(req *http.Request, options ...ExponentialBackoffOption backoff := o.initialBackoff + // Read the request body once if it exists, so we can replay it on retries. + // This is necessary because some body types (like manually wrapped + // io.NopCloser) don't have GetBody set, causing "ContentLength=N with Body + // length 0" errors when req.Clone() tries to reuse the exhausted body. + var bodyBytes []byte + if req.Body != nil { + var err error + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read request body") + } + if err := req.Body.Close(); err != nil { + return nil, errors.Wrap(err, "failed to close request body") + } + } + for attempt := 0; attempt <= o.maxRetries; attempt++ { reqClone := req.Clone(req.Context()) - if req.Body != nil { - if seeker, ok := req.Body.(interface { - Seek(int64, int) (int64, error) - }); ok { - _, _ = seeker.Seek(0, 0) - } - reqClone.Body = req.Body + if bodyBytes != nil { + reqClone.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + reqClone.ContentLength = int64(len(bodyBytes)) } resp, err := o.client.Do(reqClone) diff --git a/httputilx/httputilx_test.go b/httputilx/httputilx_test.go index 89df920..e419388 100644 --- a/httputilx/httputilx_test.go +++ b/httputilx/httputilx_test.go @@ -230,6 +230,7 @@ func TestDoExponentialBackoff(t *testing.T) { name string options []ExponentialBackoffOption handler http.HandlerFunc + requestBody io.Reader wantBody string wantErr string wantAttempts int @@ -323,6 +324,55 @@ func TestDoExponentialBackoff(t *testing.T) { wantErr: "", wantAttempts: 3, }, + { + name: "RequestBodyCopiedOnRetry", + options: []ExponentialBackoffOption{ + ExponentialBackoffWithConfig(4, 100*time.Millisecond, 5*time.Second, 2.0), + }, + handler: func() http.HandlerFunc { + initialBody := "request body content" + + attempts := 0 + return func(w http.ResponseWriter, r *http.Request) { + attempts++ + + if r.ContentLength != int64(len(initialBody)) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprintf(w, "wrong content-length: got %d, want %d", r.ContentLength, len(initialBody)) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + if len(body) != len(initialBody) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprintf(w, "content-length mismatch: header=%d actual=%d", r.ContentLength, len(body)) + return + } + + // Verify body is correctly sent on all attempts + if string(body) != initialBody { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprintf(w, "incorrect body: %q", string(body)) + return + } + if attempts < 3 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("body received correctly")) + } + }(), + requestBody: io.NopCloser(bytes.NewBuffer([]byte("request body content"))), + wantBody: "body received correctly", + wantErr: "", + wantAttempts: 3, + }, } for _, tt := range tests { @@ -334,7 +384,7 @@ func TestDoExponentialBackoff(t *testing.T) { })) defer ts.Close() - req, err := http.NewRequest(http.MethodGet, ts.URL, nil) + req, err := http.NewRequest(http.MethodGet, ts.URL, tt.requestBody) if err != nil { t.Fatalf("failed to create request: %v", err) } From a5e76944b5e7e0d05201e1edb17830cc5da6c2c5 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Wed, 7 Jan 2026 07:15:42 -0300 Subject: [PATCH 2/3] New cleaner approach covering more scenarios --- httputilx/httputilx.go | 76 +++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/httputilx/httputilx.go b/httputilx/httputilx.go index 8aa79f3..a98389f 100644 --- a/httputilx/httputilx.go +++ b/httputilx/httputilx.go @@ -232,27 +232,10 @@ func DoExponentialBackoff(req *http.Request, options ...ExponentialBackoffOption backoff := o.initialBackoff - // Read the request body once if it exists, so we can replay it on retries. - // This is necessary because some body types (like manually wrapped - // io.NopCloser) don't have GetBody set, causing "ContentLength=N with Body - // length 0" errors when req.Clone() tries to reuse the exhausted body. - var bodyBytes []byte - if req.Body != nil { - var err error - bodyBytes, err = io.ReadAll(req.Body) - if err != nil { - return nil, errors.Wrap(err, "failed to read request body") - } - if err := req.Body.Close(); err != nil { - return nil, errors.Wrap(err, "failed to close request body") - } - } - for attempt := 0; attempt <= o.maxRetries; attempt++ { - reqClone := req.Clone(req.Context()) - if bodyBytes != nil { - reqClone.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - reqClone.ContentLength = int64(len(bodyBytes)) + reqClone, err := cloneWithBody(req) + if err != nil { + return nil, errors.Wrap(err, "failed to clone request with body") } resp, err := o.client.Do(reqClone) @@ -284,3 +267,56 @@ func DoExponentialBackoff(req *http.Request, options ...ExponentialBackoffOption return nil, fmt.Errorf("request failed after %d attempts", o.maxRetries+1) } + +func cloneWithBody(req *http.Request) (*http.Request, error) { + newReq := req.Clone(req.Context()) + if req.Body == nil { + return newReq, nil + } + if req.GetBody != nil { + var err error + newReq.Body, err = req.GetBody() + if err != nil { + return nil, err + } + return newReq, nil + } + + if seeker, ok := req.Body.(io.Seeker); ok { + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + return nil, err + } + newReq.Body = req.Body + newReq.GetBody = func() (io.ReadCloser, error) { + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + return nil, err + } + return req.Body, nil + } + return newReq, nil + } + + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + if err := req.Body.Close(); err != nil { + return nil, err + } + + createBody := func(bodyBytes []byte) func() (io.ReadCloser, error) { + return func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(bodyBytes)), nil + } + } + + req.GetBody = createBody(bodyBytes) + req.Body, _ = req.GetBody() + req.ContentLength = int64(len(bodyBytes)) + + newReq.GetBody = createBody(bodyBytes) + newReq.Body, _ = newReq.GetBody() + newReq.ContentLength = int64(len(bodyBytes)) + + return newReq, nil +} From 2718338f45bd1074939a63bb59fac9956c045c87 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Wed, 7 Jan 2026 07:26:31 -0300 Subject: [PATCH 3/3] Small test adjustment --- httputilx/httputilx_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/httputilx/httputilx_test.go b/httputilx/httputilx_test.go index e419388..3ae29b4 100644 --- a/httputilx/httputilx_test.go +++ b/httputilx/httputilx_test.go @@ -384,7 +384,11 @@ func TestDoExponentialBackoff(t *testing.T) { })) defer ts.Close() - req, err := http.NewRequest(http.MethodGet, ts.URL, tt.requestBody) + method := http.MethodGet + if tt.requestBody != nil { + method = http.MethodPost + } + req, err := http.NewRequest(method, ts.URL, tt.requestBody) if err != nil { t.Fatalf("failed to create request: %v", err) }