Skip to content

[Bug]: Wrapped body in http request prevents rewinding the body. #8759

@Noroth

Description

@Noroth

Component

Instrumentation: otelhttp

Describe the issue you're facing

Hey Otel team,

I'm actually not 100% sure if this is expected or not, but I still wanted to report it. I also couldn't find another issue that seemed related.

Analysis

We are using a retrying roundtripper that uses the otelhttp roundtripper. I'm currently upgrading from quite an old OTEL version otelhttp@v0.46.1 to v0.67.0 and I noticed that our tests were suddenly failing.

I took some time to investigate and I think I found the issue:

The roundtripper creates a wrapper around the body.

var lastBW *request.BodyWrapper // Records the last body wrapper. Can be nil.
maybeWrapBody := func(body io.ReadCloser) io.ReadCloser {
if body == nil || body == http.NoBody {
return body
}
bw := request.NewBodyWrapper(body, func(int64) {})
lastBW = bw
return bw
}
r.Body = maybeWrapBody(r.Body)
if r.GetBody != nil {
originalGetBody := r.GetBody
r.GetBody = func() (io.ReadCloser, error) {
b, err := originalGetBody()
if err != nil {
lastBW = nil // The underlying transport will fail to make a retry request, hence, record no data.
return nil, err
}
return maybeWrapBody(b), nil
}
}

The net/http library has a fallback to rewind a body in case nothing was written:

https://github.com/golang/go/blob/56ebf80e57db9f61981fc0636fc6419dc6f68eda/src/net/http/transport.go#L2225-L2229
When nothing was written it returns a nothingWrittenError{err} which actually causes the request body to be rewinded later in the logic.

With the wrapped body the check fails at: https://github.com/golang/go/blob/56ebf80e57db9f61981fc0636fc6419dc6f68eda/src/net/http/transfer.go#L106-L108

The wrapped body is not a known type checked in isKnownInMemoryReader(t.Body), which sets t.FlushHeaders = true.

On the next retry request, even though the seek index is at the end of the body, we send another request that only contains the headers with a content length > 0 but an empty content.

The connection then writes the header bytes to the written bytes counter which causes a mismatch in the error mapping logic and instead of returning a nothingWrittenError we get a non rewindable error.

Mitigation

In my case the solution was quite simple. I simply clone the request body and rewind it myself before calling the underlying RoundTripper again

func cloneRequestBody(req *http.Request) error {
	if req.Body == nil || req.Body == http.NoBody {
		return nil
	}

	if req.GetBody == nil {
		return nil
	}

	clonedBody, err := req.GetBody()
	if err != nil {
		return err
	}

	req.Body = clonedBody
	return nil
}

Expected behavior

I expected the net/http logic to rewind the request properly

Steps to Reproduce

We wrapped the otelhttp RoundTripper with this https://github.com/wundergraph/cosmo/blob/main/router/internal/retrytransport/retry_transport.go.

Operating System

MacOS Version 26.2

Device Architecture

ARM64

Go Version

1.25

Component Version

otelhttp@v0.67.0

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions