Repository: wundergraph/cosmo
Pull request #1713: fix: ensure to close any responses before retrying
Author: login: SkArchon
Labels: name: router
Created: 2025-03-20T17:45:41Z | Merged: 2025-03-25T07:40:30Z

<!--
Important: Before developing new features, please open an issue to discuss your ideas with the maintainers. This ensures project alignment and helps avoid unnecessary work for you.

Thank you for your contribution! Please provide a detailed description below and ensure you've met all the requirements.

Contributors Guide: https://github.com/wundergraph/cosmo/blob/main/CONTRIBUTING.md

Squashed commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard to facilitate changelog generation.

Please ensure your PR title follows the Conventional Commits specification, using the appropriate type (e.g., feat, fix, docs) and scope.

Examples of good PR titles:

- 💥feat!: change implementation in an non-backward compatible way
- ✨feat(auth): add support for OAuth2 login
- 🐞fix(router): add support for custom metrics
- 📚docs(README): update installation instructions
- 🧹chore(deps): bump dependencies to latest versions
-->

## Motivation and Context

Right now it seems that we do not close the response body before retrying, this causes the response to be nil for the next retry. However if we close it, the response from the next retry is not nil then. W
e now make sure to read the entire stream and close it before retrying. Golang code itself mentions `It is the caller's responsibility to close Body.`

## Checklist

- [ ] I have discussed my proposed changes in an issue and have received approval to proceed.
- [ ] I have followed the coding standards of the project.
- [ ] Tests or benchmarks have been added or updated.
- [ ] Documentation has been updated on [https://github.com/wundergraph/cosmo-docs](https://github.com/wundergraph/cosmo-docs).
- [ ] I have read the [Contributors Guide](https://github.com/wundergraph/cosmo/blob/main/CONTRIBUTING.md).

<!--
Please add any additional information or context regarding your changes here.
-->


Diff:
diff --git a/router/core/transport.go b/router/core/transport.go
index 957540f8b3..ef94a99402 100644
--- a/router/core/transport.go
+++ b/router/core/transport.go
@@ -69,7 +69,16 @@ func NewCustomTransport(
 	}
 
 	if retryOptions.Enabled {
-		ct.roundTripper = retrytransport.NewRetryHTTPTransport(roundTripper, retryOptions, logger)
+		// The round trip method is almost always called via the http.Client RoundTripper interface
+		// as a result we cannot pass in the request context logger directly, since this will break the interface
+		// The RoundTripper is also not in the core package so it does not have access to the
+		// getRequestContext function since its private to only the core package
+		// As a workaround we pass in a function that can be used to get the logger from within the round tripper
+		getRequestContextLogger := func(req *http.Request) *zap.Logger {
+			reqContext := getRequestContext(req.Context())
+			return reqContext.Logger()
+		}
+		ct.roundTripper = retrytransport.NewRetryHTTPTransport(roundTripper, retryOptions, getRequestContextLogger)
 	} else {
 		ct.roundTripper = roundTripper
 	}
diff --git a/router/internal/retrytransport/retry_transport.go b/router/internal/retrytransport/retry_transport.go
index 8e992da2b8..aa9d99038a 100644
--- a/router/internal/retrytransport/retry_transport.go
+++ b/router/internal/retrytransport/retry_transport.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"github.com/cloudflare/backoff"
 	"go.uber.org/zap"
+	"io"
 	"net/http"
 	"strings"
 	"syscall"
@@ -42,22 +43,27 @@ type RetryOptions struct {
 	ShouldRetry   ShouldRetryFunc
 }
 
+type requestLoggerGetter func(req *http.Request) *zap.Logger
+
 type RetryHTTPTransport struct {
-	RoundTripper http.RoundTripper
-	RetryOptions RetryOptions
-	Logger       *zap.Logger
+	RoundTripper     http.RoundTripper
+	RetryOptions     RetryOptions
+	getRequestLogger requestLoggerGetter
 }
 
-func NewRetryHTTPTransport(roundTripper http.RoundTripper, retryOptions RetryOptions, logger *zap.Logger) *RetryHTTPTransport {
+func NewRetryHTTPTransport(
+	roundTripper http.RoundTripper,
+	retryOptions RetryOptions,
+	getRequestLogger requestLoggerGetter,
+) *RetryHTTPTransport {
 	return &RetryHTTPTransport{
-		RoundTripper: roundTripper,
-		RetryOptions: retryOptions,
-		Logger:       logger,
+		RoundTripper:     roundTripper,
+		RetryOptions:     retryOptions,
+		getRequestLogger: getRequestLogger,
 	}
 }
 
 func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
-
 	resp, err := rt.RoundTripper.RoundTrip(req)
 	// Short circuit if the request was successful.
 	if err == nil && isResponseOK(resp) {
@@ -67,6 +73,8 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 	b := backoff.New(rt.RetryOptions.MaxDuration, rt.RetryOptions.Interval)
 	defer b.Reset()
 
+	requestLogger := rt.getRequestLogger(req)
+
 	// Retry logic
 	retries := 0
 	for rt.RetryOptions.ShouldRetry(err, req, resp) && retries < rt.RetryOptions.MaxRetryCount {
@@ -79,7 +87,7 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 		// Wait for the specified backoff period
 		sleepDuration := b.Duration()
 
-		rt.Logger.Debug("Retrying request",
+		requestLogger.Debug("Retrying request",
 			zap.Int("retry", retries),
 			zap.String("url", req.URL.String()),
 			zap.Duration("sleep", sleepDuration),
@@ -88,6 +96,9 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 		// Wait for the specified backoff period
 		time.Sleep(sleepDuration)
 
+		// drain the previous response before retrying
+		rt.drainBody(resp, requestLogger)
+
 		// Retry the request
 		resp, err = rt.RoundTripper.RoundTrip(req)
 
@@ -101,6 +112,26 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 	return resp, err
 }
 
+func (rt *RetryHTTPTransport) drainBody(resp *http.Response, logger *zap.Logger) {
+	if resp == nil || resp.Body == nil {
+		return
+	}
+
+	defer func() {
+		err := resp.Body.Close()
+		if err != nil {
+			logger.Error("Failed draining when closing the body", zap.Error(err))
+		}
+	}()
+
+	// When we close the body only will go internally marks the persisted connection as true
+	// which is important so that it can reuse the connection internally for retrying
+	_, err := io.Copy(io.Discard, resp.Body)
+	if err != nil {
+		logger.Error("Failed draining when discarding the body", zap.Error(err))
+	}
+}
+
 func isResponseOK(resp *http.Response) bool {
 	// Ensure we don't wait for no reason when subgraphs don't behave
 	// spec-compliant and returns a different status code than 200.
diff --git a/router/internal/retrytransport/retry_transport_test.go b/router/internal/retrytransport/retry_transport_test.go
index 5e7cb0ebb1..117d81e46c 100644
--- a/router/internal/retrytransport/retry_transport_test.go
+++ b/router/internal/retrytransport/retry_transport_test.go
@@ -1,12 +1,18 @@
 package retrytransport
 
 import (
-	"github.com/stretchr/testify/assert"
-	"go.uber.org/zap"
+	"bytes"
+	"errors"
+	"fmt"
+	"go.uber.org/zap/zapcore"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 	"time"
+
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/zap"
 )
 
 type MockTransport struct {
@@ -18,15 +24,12 @@ func (dt *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 }
 
 func TestRetryOnHTTP5xx(t *testing.T) {
-
-	logger := zap.NewNop()
 	retries := 0
 	index := -1
 
 	tr := RetryHTTPTransport{
 		RoundTripper: &MockTransport{
 			handler: func(req *http.Request) (*http.Response, error) {
-
 				if index < len(defaultRetryableStatusCodes)-1 {
 					index++
 					return &http.Response{
@@ -39,6 +42,9 @@ func TestRetryOnHTTP5xx(t *testing.T) {
 				}
 			},
 		},
+		getRequestLogger: func(req *http.Request) *zap.Logger {
+			return zap.NewNop()
+		},
 		RetryOptions: RetryOptions{
 			MaxRetryCount: len(defaultRetryableStatusCodes),
 			Interval:      1 * time.Millisecond,
@@ -50,7 +56,6 @@ func TestRetryOnHTTP5xx(t *testing.T) {
 				retries++
 			},
 		},
-		Logger: logger,
 	}
 
 	req := httptest.NewRequest("GET", "http://localhost:3000/graphql", nil)
@@ -65,8 +70,6 @@ func TestRetryOnHTTP5xx(t *testing.T) {
 }
 
 func TestRetryOnNetErrors(t *testing.T) {
-
-	logger := zap.NewNop()
 	retries := 0
 	index := -1
 
@@ -84,6 +87,9 @@ func TestRetryOnNetErrors(t *testing.T) {
 				}
 			},
 		},
+		getRequestLogger: func(req *http.Request) *zap.Logger {
+			return zap.NewNop()
+		},
 		RetryOptions: RetryOptions{
 			MaxRetryCount: len(defaultRetryableErrors),
 			Interval:      1 * time.Millisecond,
@@ -95,7 +101,6 @@ func TestRetryOnNetErrors(t *testing.T) {
 				retries++
 			},
 		},
-		Logger: logger,
 	}
 
 	req := httptest.NewRequest("GET", "http://localhost:3000/graphql", nil)
@@ -108,3 +113,227 @@ func TestRetryOnNetErrors(t *testing.T) {
 	assert.Equal(t, len(defaultRetryableErrors), retries)
 
 }
+
+func TestDoNotRetryWhenErrorIsNotRetryableAndResponseIsNil(t *testing.T) {
+	finalError := errors.New("some error")
+
+	expectedRetries := 2
+	retries := 0
+	index := -1
+	maxRetryCount := 7
+
+	tr := RetryHTTPTransport{
+		RoundTripper: &MockTransport{
+			handler: func(req *http.Request) (*http.Response, error) {
+				index++
+				switch index {
+				case 0:
+					// The first retry we return a retryable error
+					return &http.Response{StatusCode: defaultRetryableStatusCodes[0]}, nil
+				case 1:
+					// The second retry we return a retryable status code
+					return nil, defaultRetryableErrors[index]
+				default:
+					// The third retry we return a nil response as well as a non-retryable error
+					return nil, finalError
+				}
+			},
+		},
+		getRequestLogger: func(req *http.Request) *zap.Logger {
+			return zap.NewNop()
+		},
+		RetryOptions: RetryOptions{
+			MaxRetryCount: maxRetryCount,
+			Interval:      1 * time.Millisecond,
+			MaxDuration:   10 * time.Millisecond,
+			ShouldRetry: func(err error, req *http.Request, resp *http.Response) bool {
+				return IsRetryableError(err, resp)
+			},
+			OnRetry: func(count int, req *http.Request, resp *http.Response, err error) {
+				retries++
+			},
+		},
+	}
+
+	req := httptest.NewRequest("GET", "http://localhost:3000/graphql", nil)
+
+	resp, err := tr.RoundTrip(req)
+	assert.Error(t, finalError, err)
+	assert.Nil(t, resp)
+
+	assert.Equal(t, expectedRetries, retries)
+	assert.NotEqual(t, maxRetryCount, retries)
+}
+
+// TrackableBody is a custom io.ReadCloser that tracks if it's been read and closed
+type TrackableBody struct {
+	index  int
+	read   bool
+	closed bool
+
+	throwOnRead  bool
+	throwOnClose bool
+}
+
+func (b *TrackableBody) Read(p []byte) (n int, err error) {
+	b.read = true
+
+	if b.throwOnRead {
+		return 0, fmt.Errorf("retry read error, index: %d", b.index)
+	}
+	return 0, io.EOF
+}
+
+func (b *TrackableBody) Close() error {
+	b.closed = true
+
+	if b.throwOnClose {
+		return fmt.Errorf("retry close error, index: %d", b.index)
+	}
+	return nil
+}
+
+func TestResponseBodyDraining(t *testing.T) {
+	actualRetries := 0
+	index := -1
+
+	// Create trackable bodies for each response
+	retryCount := 2
+
+	bodies := make([]*TrackableBody, retryCount+1)
+	for i := range bodies {
+		bodies[i] = &TrackableBody{
+			index: i,
+		}
+	}
+
+	tr := RetryHTTPTransport{
+		RoundTripper: &MockTransport{
+			handler: func(req *http.Request) (*http.Response, error) {
+				index++
+				if index < retryCount {
+					return &http.Response{
+						StatusCode: defaultRetryableStatusCodes[0],
+						Body:       bodies[index],
+					}, nil
+				} else {
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       bodies[index],
+					}, nil
+				}
+			},
+		},
+		getRequestLogger: func(req *http.Request) *zap.Logger {
+			return zap.NewNop()
+		},
+		RetryOptions: RetryOptions{
+			MaxRetryCount: retryCount,
+			Interval:      1 * time.Millisecond,
+			MaxDuration:   10 * time.Millisecond,
+			ShouldRetry: func(err error, req *http.Request, resp *http.Response) bool {
+				return IsRetryableError(err, resp)
+			},
+			OnRetry: func(count int, req *http.Request, resp *http.Response, err error) {
+				actualRetries++
+			},
+		},
+	}
+
+	req := httptest.NewRequest("GET", "http://localhost:3000/graphql", nil)
+
+	resp, err := tr.RoundTrip(req)
+	assert.Nil(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	assert.Equal(t, retryCount, actualRetries)
+
+	// Verify all bodies were read and closed
+	for i, body := range bodies {
+		if i < retryCount {
+			assert.True(t, body.read, fmt.Sprintf("Body %d was not read", i))
+			assert.True(t, body.closed, fmt.Sprintf("Body %d was not closed", i))
+		} else {
+			// the final successful body should not be read
+			assert.False(t, body.read, fmt.Sprintf("Body %d was read when it shouldnt be", i))
+			assert.False(t, body.closed, fmt.Sprintf("Body %d was closed when it shouldnt be", i))
+		}
+	}
+}
+
+func TestRequestLoggerIsUsed(t *testing.T) {
+	requestLoggerBuf, requestLogger := createTestLogger(t)
+
+	actualRetries := 0
+	index := -1
+
+	// Create trackable bodies for each response
+	retryCount := 5
+
+	bodies := make([]*TrackableBody, retryCount+1)
+	for i := range bodies {
+		trackableBody := &TrackableBody{
+			index: i,
+		}
+
+		switch i {
+		case 1:
+			trackableBody.throwOnRead = true
+		case 2:
+			trackableBody.throwOnClose = true
+		}
+		bodies[i] = trackableBody
+	}
+
+	tr := RetryHTTPTransport{
+		RoundTripper: &MockTransport{
+			handler: func(req *http.Request) (*http.Response, error) {
+				index++
+				if index < retryCount {
+					return &http.Response{
+						StatusCode: defaultRetryableStatusCodes[0],
+						Body:       bodies[index],
+					}, nil
+				} else {
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       bodies[index],
+					}, nil
+				}
+			},
+		},
+		getRequestLogger: func(req *http.Request) *zap.Logger {
+			return requestLogger
+		},
+		RetryOptions: RetryOptions{
+			MaxRetryCount: retryCount,
+			Interval:      1 * time.Millisecond,
+			MaxDuration:   10 * time.Millisecond,
+			ShouldRetry: func(err error, req *http.Request, resp *http.Response) bool {
+				return IsRetryableError(err, resp)
+			},
+			OnRetry: func(count int, req *http.Request, resp *http.Response, err error) {
+				actualRetries++
+			},
+		},
+	}
+
+	req := httptest.NewRequest("GET", "http://localhost:3000/graphql", nil)
+
+	tr.RoundTrip(req)
+
+	assert.Contains(t, requestLoggerBuf.String(), "Failed draining when discarding the body\t{\"error\": \"retry read error, index: 1\"}")
+	assert.Contains(t, requestLoggerBuf.String(), "Failed draining when closing the body\t{\"error\": \"retry close error, index: 2\"}")
+}
+
+func createTestLogger(t *testing.T) (*bytes.Buffer, *zap.Logger) {
+	t.Helper()
+
+	var buf bytes.Buffer
+	core := zapcore.NewCore(
+		zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
+		zapcore.AddSync(&buf),
+		zapcore.InfoLevel,
+	)
+	unusedLogger := zap.New(core)
+	return &buf, unusedLogger
+}


Reviews:
user: login: Noroth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T20:38:21Z
user: login: Noroth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T20:41:54Z
user: login: SkArchon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T10:45:40Z
user: login: SkArchon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T11:11:54Z
user: login: Noroth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T00:48:27Z
user: login: StarpTech
body: Please add additional tests for retries as discussed.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T10:05:51Z
user: login: StarpTech
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T14:54:04Z
user: login: StarpTech
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T20:12:46Z
user: login: StarpTech
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T22:20:15Z

Review comments:
diff_hunk: @@ -101,6 +104,20 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 	return resp, err
 }
 
+func (rt *RetryHTTPTransport) drainBody(resp *http.Response) {
+	if resp == nil || resp.Body == nil {
+		return
+	}
+	_, err := io.Copy(io.Discard, resp.Body)
path: router/internal/retrytransport/retry_transport.go
user: login: Noroth
body: Do we need the extra step to discard the content? Shouldn't close be sufficient?
created_at: 2025-03-20T20:38:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,6 +104,20 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 	return resp, err
 }
 
+func (rt *RetryHTTPTransport) drainBody(resp *http.Response) {
+	if resp == nil || resp.Body == nil {
+		return
+	}
+	_, err := io.Copy(io.Discard, resp.Body)
+	if err != nil {
+		rt.Logger.Error("Failed draining when copying the body", zap.Error(err))
+	}
+	err = resp.Body.Close()
path: router/internal/retrytransport/retry_transport.go
user: login: Noroth
body: We probably want to do something like defer body.Close() at the beginning of the function to make sure we always at least attempt to close the body, right?
created_at: 2025-03-20T20:41:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,6 +104,20 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 	return resp, err
 }
 
+func (rt *RetryHTTPTransport) drainBody(resp *http.Response) {
+	if resp == nil || resp.Body == nil {
+		return
+	}
+	_, err := io.Copy(io.Discard, resp.Body)
+	if err != nil {
+		rt.Logger.Error("Failed draining when copying the body", zap.Error(err))
+	}
+	err = resp.Body.Close()
path: router/internal/retrytransport/retry_transport.go
user: login: SkArchon
body: I didn't use defer because we don't early return on errors. But nothing against moving to defer, will change.
created_at: 2025-03-21T10:45:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,6 +104,20 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 	return resp, err
 }
 
+func (rt *RetryHTTPTransport) drainBody(resp *http.Response) {
+	if resp == nil || resp.Body == nil {
+		return
+	}
+	_, err := io.Copy(io.Discard, resp.Body)
path: router/internal/retrytransport/retry_transport.go
user: login: SkArchon
body: Yes it's needed, apparently closing the body didn't make go mark `reused=true` internally, but reading the entire body, even without closing did.
created_at: 2025-03-21T11:11:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,6 +104,26 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 	return resp, err
 }
 
+func (rt *RetryHTTPTransport) drainBody(resp *http.Response) {
+	if resp == nil || resp.Body == nil {
+		return
+	}
+
+	defer func() {
+		err := resp.Body.Close()
+		if err != nil {
+			rt.Logger.Error("Failed draining when closing the body", zap.Error(err))
+		}
+	}()
+
+	// When we close the body only will go internally marks the persisted connection as true
+	// which is important so that it can reuse the connection internally for retrying
+	_, err := io.Copy(io.Discard, resp.Body)
+	if err != nil {
+		rt.Logger.Error("Failed draining when copying the body", zap.Error(err))
path: router/internal/retrytransport/retry_transport.go
user: login: Noroth
body: nit 
```suggestion
		rt.Logger.Error("Failed draining when discarding the body", zap.Error(err))
```
created_at: 2025-03-22T00:48:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,6 +104,26 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 	return resp, err
 }
 
+func (rt *RetryHTTPTransport) drainBody(resp *http.Response) {
+	if resp == nil || resp.Body == nil {
+		return
+	}
+
+	defer func() {
+		err := resp.Body.Close()
+		if err != nil {
+			rt.Logger.Error("Failed draining when closing the body", zap.Error(err))
+		}
+	}()
+
+	// When we close the body only will go internally marks the persisted connection as true
+	// which is important so that it can reuse the connection internally for retrying
+	_, err := io.Copy(io.Discard, resp.Body)
+	if err != nil {
+		rt.Logger.Error("Failed draining when discarding the body", zap.Error(err))
path: router/internal/retrytransport/retry_transport.go
user: login: StarpTech
body: This must be the request logger.
created_at: 2025-03-24T14:53:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,6 +104,26 @@ func (rt *RetryHTTPTransport) RoundTrip(req *http.Request) (*http.Response, erro
 	return resp, err
 }
 
+func (rt *RetryHTTPTransport) drainBody(resp *http.Response) {
+	if resp == nil || resp.Body == nil {
+		return
+	}
+
+	defer func() {
+		err := resp.Body.Close()
+		if err != nil {
+			rt.Logger.Error("Failed draining when closing the body", zap.Error(err))
path: router/internal/retrytransport/retry_transport.go
user: login: StarpTech
body: Here too.
created_at: 2025-03-24T14:53:21Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-20T17:49:13Z
body: <!-- add-pr-comment:add-pr-comment -->

# Router image scan passed
:white_check_mark: No security [vulnerabilities](https://github.com/wundergraph/cosmo/security/code-scanning?query=pr%3A1713+is%3Aopen+tool%3ATrivy) found in image:

```
ghcr.io/wundergraph/cosmo/router:sha-f27aef0f69d33ddab444dbf65adb03d1703a89bd
```
author_association: NONE
user: login: SkArchon
created_at: 2025-03-21T11:12:34Z
body: When we close the body the following function in `http/transport` is called, so if we DON'T call drain and close the body `pc.reused` will be `false`

```
// markReused marks this connection as having been successfully used for a
// request and response.
func (pc *persistConn) markReused() {
	pc.mu.Lock()
	pc.reused = true
	pc.mu.Unlock()
}
```

Which means this affects the following on a retry RoundTrip call

```
if !pc.isReused() {
        // This was a fresh connection. There's no reason the server
	// should've hung up on us.
	//
	// Also, if we retried now, we could loop forever
	// creating new connections and retrying if the server
	// is just hanging up on us because it doesn't like
	// our request (as opposed to sending an error).
	return false
}
if _, ok := err.(nothingWrittenError); ok {
	// We never wrote anything, so it's safe to retry, if there's no body or we
	// can "rewind" the body with GetBody.
	return req.outgoingLength() == 0 || req.GetBody != nil
}
```

For retries the code after the `pc.isReused()` check is called, notably in the case of my testing `if _, ok := err.(nothingWrittenError); ok {` needs to be called to successfully retry, this is internally this error is thrown when we try to retry since the body is already read, then the retry *seems* to be retried when this error is present and the body is `rewindable`, which is the case since from my testing it used `bytes.Buffer` for the body.
author_association: CONTRIBUTOR