Skip to content

Commit 067a464

Browse files
committed
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.
1 parent 929cb93 commit 067a464

File tree

2 files changed

+70
-8
lines changed

2 files changed

+70
-8
lines changed

httputilx/httputilx.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -232,15 +232,27 @@ func DoExponentialBackoff(req *http.Request, options ...ExponentialBackoffOption
232232

233233
backoff := o.initialBackoff
234234

235+
// Read the request body once if it exists, so we can replay it on retries.
236+
// This is necessary because some body types (like manually wrapped
237+
// io.NopCloser) don't have GetBody set, causing "ContentLength=N with Body
238+
// length 0" errors when req.Clone() tries to reuse the exhausted body.
239+
var bodyBytes []byte
240+
if req.Body != nil {
241+
var err error
242+
bodyBytes, err = io.ReadAll(req.Body)
243+
if err != nil {
244+
return nil, errors.Wrap(err, "failed to read request body")
245+
}
246+
if err := req.Body.Close(); err != nil {
247+
return nil, errors.Wrap(err, "failed to close request body")
248+
}
249+
}
250+
235251
for attempt := 0; attempt <= o.maxRetries; attempt++ {
236252
reqClone := req.Clone(req.Context())
237-
if req.Body != nil {
238-
if seeker, ok := req.Body.(interface {
239-
Seek(int64, int) (int64, error)
240-
}); ok {
241-
_, _ = seeker.Seek(0, 0)
242-
}
243-
reqClone.Body = req.Body
253+
if bodyBytes != nil {
254+
reqClone.Body = io.NopCloser(bytes.NewReader(bodyBytes))
255+
reqClone.ContentLength = int64(len(bodyBytes))
244256
}
245257

246258
resp, err := o.client.Do(reqClone)

httputilx/httputilx_test.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ func TestDoExponentialBackoff(t *testing.T) {
230230
name string
231231
options []ExponentialBackoffOption
232232
handler http.HandlerFunc
233+
requestBody io.Reader
233234
wantBody string
234235
wantErr string
235236
wantAttempts int
@@ -323,6 +324,55 @@ func TestDoExponentialBackoff(t *testing.T) {
323324
wantErr: "",
324325
wantAttempts: 3,
325326
},
327+
{
328+
name: "RequestBodyCopiedOnRetry",
329+
options: []ExponentialBackoffOption{
330+
ExponentialBackoffWithConfig(4, 100*time.Millisecond, 5*time.Second, 2.0),
331+
},
332+
handler: func() http.HandlerFunc {
333+
initialBody := "request body content"
334+
335+
attempts := 0
336+
return func(w http.ResponseWriter, r *http.Request) {
337+
attempts++
338+
339+
if r.ContentLength != int64(len(initialBody)) {
340+
w.WriteHeader(http.StatusInternalServerError)
341+
_, _ = fmt.Fprintf(w, "wrong content-length: got %d, want %d", r.ContentLength, len(initialBody))
342+
return
343+
}
344+
345+
body, err := io.ReadAll(r.Body)
346+
if err != nil {
347+
w.WriteHeader(http.StatusInternalServerError)
348+
return
349+
}
350+
351+
if len(body) != len(initialBody) {
352+
w.WriteHeader(http.StatusInternalServerError)
353+
_, _ = fmt.Fprintf(w, "content-length mismatch: header=%d actual=%d", r.ContentLength, len(body))
354+
return
355+
}
356+
357+
// Verify body is correctly sent on all attempts
358+
if string(body) != initialBody {
359+
w.WriteHeader(http.StatusInternalServerError)
360+
_, _ = fmt.Fprintf(w, "incorrect body: %q", string(body))
361+
return
362+
}
363+
if attempts < 3 {
364+
w.WriteHeader(http.StatusInternalServerError)
365+
return
366+
}
367+
w.WriteHeader(http.StatusOK)
368+
_, _ = w.Write([]byte("body received correctly"))
369+
}
370+
}(),
371+
requestBody: io.NopCloser(bytes.NewBuffer([]byte("request body content"))),
372+
wantBody: "body received correctly",
373+
wantErr: "",
374+
wantAttempts: 3,
375+
},
326376
}
327377

328378
for _, tt := range tests {
@@ -334,7 +384,7 @@ func TestDoExponentialBackoff(t *testing.T) {
334384
}))
335385
defer ts.Close()
336386

337-
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
387+
req, err := http.NewRequest(http.MethodGet, ts.URL, tt.requestBody)
338388
if err != nil {
339389
t.Fatalf("failed to create request: %v", err)
340390
}

0 commit comments

Comments
 (0)