skip to content

Retries, Backoff and Idempotency

Retrying in Go runs into a consumed req.Body, so a replay needs req.GetBody or a fresh reader, and the retry itself belongs in one http.RoundTripper rather than at every call site.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

Why does retrying the same *http.Request send an empty body on the second attempt?

level: juniorimportance: must knowfreq 55%

answer

  1. a body is a stream, not a buffer
  2. who reads it, and who closes it
  3. the second attempt starts at EOF
  4. ContentLength still promises the bytes
  5. rebuild the request every attempt

basics

~20 s

A request body is a one-shot io.ReadCloser. The first Do drains it and the Transport closes it, so a second Do on the same *http.Request finds nothing left to read. Build a fresh request for every attempt.

solid answer

~40 s

`http.Request.Body` is an `io.ReadCloser`, a stream, not a buffer. The first `Client.Do` reads it to the wire and the Transport closes it — net/http documents that it closes the request body even when the call fails — so a second `Do` on the same request starts at EOF. Usually you do not even get a silent empty POST: `ContentLength` still promises the original number of bytes, so the write fails with an error saying the body length does not match `ContentLength`. In a retry loop, keep the payload as a `[]byte` and build a new request each attempt with `http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))`, or call `req.GetBody()` to get a fresh reader before each attempt. Never hand one `*http.Request` to `Do` twice.

code

go · 17 lines
go
func deliver(ctx context.Context, endpoint string, payload []byte) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
	if err != nil {
		return err
	}
	var lastErr error
	for attempt := 0; attempt < 3; attempt++ {
		resp, err := http.DefaultClient.Do(req) // attempt 2 finds the body drained and closed
		if err != nil {
			lastErr = err
			continue
		}
		resp.Body.Close()
		return nil
	}
	return lastErr
}

go deeper

for a junior

Be ready to say that a request body is an io.ReadCloser that is read once and closed by the Transport, and that the fix is to construct a new request from the same bytes on each attempt.

for a middle

Explain the mechanics: who closes the body, why ContentLength causes a loud error instead of a silent empty POST, and how Request.GetBody hands you a fresh reader over the same payload.

for a senior

Show the judgment about replayability: which bodies you buffer so retries are possible, what that memory costs per in-flight delivery, and how you test it against a server that fails the first attempts.

for a principal

Own the tradeoff between streaming large payloads cheaply and buffering them so delivery can be retried at all, and be clear that making a request replayable is a decision with a memory bill attached.

## The body is a stream, not a value `http.Request` carries its payload in a field of type `io.ReadCloser`: ```go type Request struct { Body io.ReadCloser // ... } ``` An `io.Reader` is a source you pull bytes from until it reports `io.EOF`. It has no rewind, no length and no memory of what it already produced. When you write `bytes.NewReader(payload)` you get a `*bytes.Reader` positioned at offset zero; every `Read` moves that offset forward. Once it has yielded the last byte, further reads return `0, io.EOF` forever unless something explicitly seeks it back. That is the whole explanation for the empty second attempt. `http.Client.Do` hands the request to the `Transport`, which streams `req.Body` onto the connection and then closes it. The net/http documentation is explicit that the Transport closes the request body — including when the request fails — so by the time `Do` returns to you, whether it succeeded or not, that body is consumed and closed. Calling `Do` again with the same `*http.Request` presents an already-drained, already-closed reader. ## What you usually see instead of an empty POST When `http.NewRequest` or `http.NewRequestWithContext` is given a `*bytes.Buffer`, `*bytes.Reader` or `*strings.Reader`, it also records `req.ContentLength` from that value's length. On the retry, the Transport still believes it must write, say, 214 bytes, but the reader hands it zero. Rather than send a truncated request it fails the write with an error along the lines of `http: ContentLength=214 with Body length 0`. So the symptom in the wild is often a confusing client-side error on attempt two, not a mysterious blank payload at the server — though a body of unknown length can indeed arrive empty. ## The shape that gets this wrong A webhook delivery worker draining a queue is the classic case. It pops a job, marshals the event to JSON, builds one request, and loops: ```go req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) for attempt := 0; attempt < 3; attempt++ { resp, err := client.Do(req) // attempt 2 has no bytes left // ... } ``` The request object looks reusable because it is a plain struct with a URL and headers, and nothing about the code says "consumed". The Transport may also have mutated the request on the way out (host, transfer encoding, the closed body itself), so reuse is unsound beyond the body too. ## The two correct fixes **Rebuild the request per attempt.** Hold the payload as a `[]byte` for the life of the retry loop and construct a new `*http.Request` each time around, wrapping the same slice in a new `bytes.NewReader`. This is the version to reach for by default: it is obviously correct, it re-derives `ContentLength` every time, and it gives you a clean place to attach a per-attempt context. **Use `Request.GetBody`.** That field is a `func() (io.ReadCloser, error)` whose job is to produce a fresh reader over the same bytes. `http.NewRequestWithContext` populates it for you when the body you passed was a `*bytes.Buffer`, `*bytes.Reader` or `*strings.Reader`. A retry loop can therefore call `body, err := req.GetBody()` and assign the result to a cloned request's `Body` before each attempt. If you built the body from some other reader, `GetBody` is nil and you must set it (and `ContentLength`) yourself, or accept that the request is not replayable. ## What replayability costs Both fixes have the same price: the payload must stay in memory for as long as you might retry. Streaming a large upload straight from an `io.Reader` is cheaper, but a stream that cannot be re-opened cannot be replayed — that is a real design tradeoff, not an oversight. For a delivery worker, holding the JSON payload is usually fine, but remember that the memory is per in-flight delivery: concurrency times payload size. ## Proving it in a test Stand up an `httptest.NewServer` whose handler counts calls, fails the first two with a 500, then succeeds, and have it read and record the body it received on every call. Point your worker at `srv.URL`, run one delivery, and assert that all three recorded bodies are byte-identical. The broken version fails that assertion (or errors out) on the second call, and the assertion keeps failing loudly if someone later "optimises" the loop by hoisting the request construction out of it. ## The mental model to keep A response body is something you read once and close; a request body is something the client reads once and closes. Retrying is not re-sending an object, it is performing the write again — and a write needs its bytes again.

  • What does holding the payload as a []byte for replay actually cost a delivery worker?
    Memory proportional to in-flight deliveries times payload size, held for the whole retry window rather than the duration of one write. That is fine for small JSON events and expensive for multi-megabyte uploads, which is why streaming bodies are often deliberately made non-replayable: you cannot re-read a pipe or a network stream you did not buffer.
  • Is it safe to reuse an *http.Request for a second call if it has no body at all?
    It is still a bad habit. A GET with a nil body will usually work, but the Transport is allowed to look at and adjust fields on the request it is given, and your own code may have mutated headers between attempts. Use req.Clone(ctx) to get an independent copy, or just build a new request — it costs nothing.
  • If the first attempt is cancelled halfway through writing the body, what state is the *bytes.Reader in?
    Partially consumed, and the Transport has already closed the Body wrapper. The reader's offset sits wherever the write stopped, so a replay from that same reader would send a truncated payload. A retry must start from a fresh reader over the original bytes, which is exactly what GetBody or rebuilding the request gives you.

A request body is a tape you play into the socket, not a record you keep. Play it twice and the second playback is just the silence after the end.

saying these in an interview costs you the question

  • Claims net/http rewinds the request body automatically before each attempt
  • Reuses a single *http.Request across several Client.Do calls
  • Thinks the Transport buffers every request body in memory for you
  • Blames the receiving server for logging an empty POST
  • Adjusts ContentLength by hand instead of rebuilding the request
open as a page

How should a retry loop wait between attempts so a cancelled context.Context ends it immediately?

level: middleimportance: should knowfreq 45%

basics

~10 s

Never time.Sleep. Start a time.Timer for the delay and select over ctx.Done() and the timer's channel, returning ctx.Err() when cancellation wins. Stop the timer on the way out so it is released early.

open as a page

What is http.Request.GetBody, and which body types make net/http fill it in?

level: middleimportance: should knowfreq 40%

basics

~20 s

GetBody is a field on http.Request holding a func that returns a fresh copy of the request body. http.NewRequest and NewRequestWithContext populate it only for *bytes.Buffer, *bytes.Reader and *strings.Reader; any other reader leaves it nil.

open as a page

Should a retry loop live in a custom http.RoundTripper or in a wrapper around Client.Do?

level: seniorimportance: should knowfreq 33%

basics

~20 s

A RoundTripper retries invisibly under the Client, so redirects, cookies and the single Client.Timeout all sit above every attempt. A wrapper around Do sits above them and can rebuild requests freely, but only helps callers who use it.

open as a page

Your webhook worker's retries cause duplicate deliveries upstream. How do you decide what it may replay?

level: principalimportance: nice to knowfreq 25%

basics

~20 s

Treat replay as a decision about the receiver's data, not your client's convenience. Decide per endpoint with the team absorbing the duplicates, make the payload buffering that enables replay explicit and bounded, and let a route's replay be switched off without a release.

open as a page