skip to content

Why does Go's http.Client stop at a 307 when the POST body came from an io.Reader?

level: middleimportance: nice to knowfreq 28%

answer

  1. some 3xx keep the method and the body
  2. a stream cannot be rewound
  3. the request needs a body factory
  4. NewRequest only buffers three body types
  5. GetBody is nil, so no replay

basics

~20 s

A 307 preserves the method and the body, so the client must send the payload again. http.NewRequest sets Request.GetBody only for buffered bodies such as *bytes.Reader; with a plain io.Reader it is nil, so the client returns the 307 unfollowed.

solid answer

~40 s

Replaying a hop that keeps the original method also means keeping the original body, and a body that arrived as an `io.Reader` has already been consumed and cannot be rewound. Go handles that with `Request.GetBody func() (io.ReadCloser, error)`, a factory that returns a fresh copy of the body. `http.NewRequest` and `http.NewRequestWithContext` populate it automatically when the body you pass is a `*bytes.Buffer`, `*bytes.Reader` or `*strings.Reader`, because those can be re-read. Hand them an `*os.File`, a pipe or any other reader and `GetBody` stays nil. When the client then meets a 307 or 308 on a request with a non-empty body and no `GetBody`, it does not error — it stops following and returns the 3xx response to you. The fixes are to buffer the payload into a `*bytes.Reader`, or to set `req.GetBody` yourself.

code

go · 9 lines
go
f, err := os.Open("payload.json")
if err != nil {
	return err
}
defer f.Close()

// *os.File is not one of the buffered body types, so GetBody stays nil
// and the client cannot re-send this body on a 307 or 308.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, f)

go deeper

for a junior

Know that Request.GetBody exists and that http.NewRequest fills it in only for in-memory bodies like *bytes.Reader and *strings.Reader. Recognise that a stream body cannot be sent twice.

for a middle

Explain why 307 and 308 need a body factory at all, what the client does when GetBody is nil, and both fixes: buffer into a *bytes.Reader, or write the GetBody closure yourself.

for a senior

Show that you would catch the silent failure: a 3xx returned with a nil error, treated as success by a call site that only checks err. Say when buffering is the wrong answer because the payload is too large.

for a principal

Own the standard: whether the platform's shared HTTP client wrapper always guarantees replayable bodies, and whether a moved upload endpoint should be a loud failure rather than a per-team surprise.

## The problem GetBody exists to solve An HTTP request body in Go is an `io.ReadCloser`. Streams are one-shot: once the transport has read your `*os.File` to EOF and sent it, there is no way to produce those bytes again. That is fine for a single request, and it is a problem the moment the client wants to send the *same* request twice — which is exactly what a 307 or 308 asks for, since those two statuses require the original method and body to be preserved on the next hop. Go's answer is a body *factory* on the request: ``` type Request struct { Body io.ReadCloser GetBody func() (io.ReadCloser, error) // ... } ``` `GetBody`, when non-nil, returns a brand-new reader over the same payload. The client calls it to build the replay. HTTP/2 uses it for its own retries as well. ## When it is filled in for you `http.NewRequest` and `http.NewRequestWithContext` inspect the concrete type of the body you pass. If it is `*bytes.Buffer`, `*bytes.Reader` or `*strings.Reader`, they know the length and know the data is still in memory, so they set `ContentLength` and install a `GetBody` that hands out a fresh reader over the same bytes. For any other `io.Reader` — an `*os.File`, an `io.Pipe`, a decompressing wrapper, a `json.NewDecoder` source, a network connection — they cannot make that guarantee. `ContentLength` is left at -1 (chunked) and `GetBody` is left nil. This is a silent difference. Both requests look identical at the call site; only the concrete type of the second argument decides whether the request is replayable. ## What the client does when it cannot replay Faced with a 307 or 308, the client checks whether the original request had a body and whether `GetBody` is available. If there was a body and there is no way to regenerate it, the client makes a deliberate choice: rather than fail, it **stops following** and returns the 3xx response to the caller, with a nil error. That is the surprising part in practice. Your code does: ``` resp, err := client.Do(req) if err != nil { ... } // resp.StatusCode == 308 ``` No error, no redirect followed, and a status your success path was not written for. A handler that only distinguishes 2xx from error will silently treat the upload as done when nothing was uploaded to the real destination. The older 3xx codes do not hit this at all: they do not require the body to be carried forward, so a nil `GetBody` costs nothing there. ## Fixing it **Buffer the payload.** If it fits in memory, read it once and wrap it: ``` data, err := io.ReadAll(src) // ... req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) ``` `GetBody` is now set for you, `ContentLength` is exact, and the hop replays. The cost is holding the whole payload; for a multi-gigabyte upload that is not acceptable. **Set GetBody yourself.** If you can re-open the source, provide the factory explicitly: ``` req.GetBody = func() (io.ReadCloser, error) { return os.Open(path) } ``` Each call returns a fresh reader positioned at the start. Set `req.ContentLength` too if you know it. This is the streaming-friendly option, and it is the one to reach for when the payload is a file on disk you can open again. **Or decide not to follow.** For a large one-shot upload, the honest answer is often to refuse redirects entirely — install a `CheckRedirect` that returns an error — and treat a 307 from the upload endpoint as a configuration problem to fix at the endpoint, not something to paper over on every call. ## Detecting it in review The tell is `http.NewRequest(..., someReader)` where `someReader` is not one of the three buffered types, on a request whose endpoint might move. In a code review, the question to ask is: if this endpoint ever answers 308, what does this call site do? If the answer is that it silently reports success on a 308, the request needs a `GetBody`.

  • The payload only exists as a stream you cannot buffer. What are the options?
    Set req.GetBody to a closure that re-opens the source — os.Open on a path is the usual case — and set req.ContentLength if you know it. If the source genuinely cannot be reproduced, do not pretend: install a CheckRedirect that returns an error so a moved endpoint fails loudly instead of returning a 3xx your success path will mishandle.
  • Which redirect statuses make the client reach for GetBody?
    Only 307 and 308, the two that require the original method and body to be preserved. For the older 3xx codes the client does not need to carry a body forward at all, so leaving GetBody nil costs you nothing on those and the hop is followed normally.
  • How would you catch this in review before it reaches production?
    Look for http.NewRequest or http.NewRequestWithContext where the body argument is not *bytes.Buffer, *bytes.Reader or *strings.Reader, on any endpoint that could move. Ask what the call site does with a 308: if it treats a non-2xx as success or ignores the status, the missing GetBody has turned a moved endpoint into silent data loss.

A buffered body is a script you can photocopy for the next take; a plain io.Reader is a live broadcast — once it has gone out, there is nothing left to send again.

saying these in an interview costs you the question

  • Thinks the client errors out rather than returning the 3xx
  • Believes any io.Reader body can be replayed
  • Assumes GetBody must always be set by hand
  • Expects the client to rewind the original Body
  • Treats a returned 308 as a successful upload