Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions httputilx/httputilx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment thread
rafaeljusto marked this conversation as resolved.
Outdated
Comment thread
rafaeljusto marked this conversation as resolved.
Outdated
}
Comment thread
rafaeljusto marked this conversation as resolved.
Outdated

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)
Expand Down
52 changes: 51 additions & 1 deletion httputilx/httputilx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Comment thread
rafaeljusto marked this conversation as resolved.
Outdated
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
Expand Down