skip to content

A wrapping http.RoundTripper times RoundTrip — what span of the outbound call does that measure?

level: middleimportance: should knowfreq 38%

answer

  1. one method, wrapped around every call
  2. when exactly does RoundTrip return?
  3. the body is a stream, not bytes
  4. headers parsed, payload still on the wire
  5. close the body to measure the drain

basics

~20 s

It measures from entering the transport to the response headers being available: connection acquisition, sending the request, and waiting for the first response bytes. It excludes reading the response body, which the caller does after RoundTrip returns.

solid answer

~50 s

`RoundTrip` returns as soon as the base transport has the response status and headers; the body is a live stream the caller reads afterwards. So a timer wrapped around it covers getting a connection — a DNS lookup, dial and TLS handshake if none is free, or just taking an idle one — writing the request including its body, and waiting for the response headers. That is effectively time-to-first-byte, not total upstream time. For an upstream that streams a large payload, the number under-reports badly. Redirects and cookie handling live in `http.Client` above the transport, so each redirect hop is a separate `RoundTrip` with its own duration. If I need the full picture I replace `resp.Body` with a wrapper whose `Close` records the elapsed time, which gives me both the time-to-headers and the time-to-drained numbers.

code

go · 9 lines
go
type timingTransport struct{ base http.RoundTripper }

func (t *timingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	start := time.Now()
	resp, err := t.base.RoundTrip(req)
	// returns once the status line and headers are parsed; the body is unread
	recordUpstream(req.URL.Host, time.Since(start), err)
	return resp, err
}

go deeper

for a junior

Know that http.RoundTripper has a single RoundTrip method and that wrapping it is how a service instruments outbound HTTP calls in one place instead of at every call site.

for a middle

Explain precisely when RoundTrip returns and therefore what your timer excludes, and know that redirects and cookies are handled by http.Client above the transport.

for a senior

Diagnose the mismatch between a fast upstream metric and a slow handler by measuring the body drain separately, and keep the wrapper cheap and side-effect free because it runs on every outbound call.

for a principal

Decide what upstream latency means across the fleet — time to headers, time to drained, or both — so that per-hop numbers from different services can be compared and used against a latency objective.

## Where a transport wrapper sits `http.RoundTripper` is a one-method interface: `RoundTrip(*http.Request) (*http.Response, error)`. An `http.Client` holds one in its `Transport` field, and everything the client does around it — following redirects, applying the cookie jar, enforcing `Client.Timeout` — happens *above* the transport. Wrapping it is the standard way to instrument every outbound call a service makes without touching call sites: ```go type timingTransport struct{ base http.RoundTripper } func (t *timingTransport) RoundTrip(req *http.Request) (*http.Response, error) { start := time.Now() resp, err := t.base.RoundTrip(req) recordUpstream(req.URL.Host, time.Since(start), err) return resp, err } ``` ## What is inside the measured span When the base transport is `http.Transport`, one `RoundTrip` covers: - **Connection acquisition** — waiting for a free connection if the per-host limit is reached, and, when a new one is needed, the DNS lookup, the TCP dial and the TLS handshake. - **Writing the request**, including its body. - **Waiting for the response status line and headers to arrive and be parsed.** It also covers the transport's own internal retry of an idempotent request when it discovers the idle connection it picked was closed by the peer — that retry happens inside a single `RoundTrip`. ## What is outside it `RoundTrip` returns as soon as the headers are parsed. `resp.Body` is an open stream connected to the network; the bytes have not arrived. Everything the caller then does — `io.Copy`, `json.NewDecoder(resp.Body).Decode(&v)`, draining and closing — happens after your timer has stopped. For a small JSON reply the difference is noise. For a paginated dump, a slow upstream trickling rows, or a proxied download, the metric will confidently report 8ms for a call that occupied the goroutine for four seconds. Two more things live outside the span. Redirects: `http.Client` issues a fresh request for each hop, so three `RoundTrip` durations exist where the caller saw one `Do`. And `Client.Timeout`, which covers the whole exchange including the body read — a call can therefore fail on that timeout with a perfectly healthy `RoundTrip` duration recorded. ## Measuring the rest Because `resp.Body` is just an `io.ReadCloser`, you can swap it for a wrapper that reports when the caller is finished: ```go type timedBody struct { io.ReadCloser done func() } func (b *timedBody) Close() error { b.done() return b.ReadCloser.Close() } ``` Set `resp.Body = &timedBody{ReadCloser: resp.Body, done: ...}` before returning, and you get a second measurement covering the drain. It depends on the caller actually closing the body, which it must do anyway to release the connection back to the pool, so a missing measurement is itself a useful signal of a leaked body. ## What not to do inside RoundTrip Do not read the body inside your wrapper to time it: the caller expects an unread stream and would get an empty one. Do not modify the request in place either — the documented contract is that `RoundTrip` must not modify the request; clone it (`req.Clone(ctx)`) if you need to change anything. And keep the work small: this code runs on every outbound call, including the hot ones. ## Which number answers which question For a service fanning one inbound request out to several upstreams, the time-to-headers number tells you whether an upstream is slow to *decide*, and the time-to-drained number tells you whether it is slow to *deliver*. They fail differently and they are fixed differently, which is exactly why it is worth being able to say which one your dashboard is showing.

  • An http.Client follows two redirects. What does the transport wrapper see?
    Three separate `RoundTrip` calls, one per hop, each with its own duration and status. Redirect following lives in `http.Client`, above the transport, so no single measurement corresponds to the caller's `Do`. If you want the caller-visible latency, time around `Do` as well, or aggregate the hops by a request-scoped identifier.
  • Why should a wrapping RoundTripper avoid mutating the request it receives?
    The documented contract is that `RoundTrip` must not modify the request, because the caller — and `http.Client` during redirects and retries — may still use it. If you need to add anything, take `req.Clone(req.Context())` and modify the copy. Mutating in place produces bugs that only show up on the second attempt.
  • Your upstream latency looks great but the handler is slow. What do you check?
    Whether the slow part is the body read, which happens after `RoundTrip` returns and outside the transport-level metric. Wrap `resp.Body` so `Close` records the drain, and compare the two numbers: a small headers time with a large drain time points at a streaming or large-payload upstream rather than a slow decision.

saying these in an interview costs you the question

  • Assuming RoundTrip returns only after the full response body has arrived
  • Reading resp.Body inside the wrapper so the caller receives an empty stream
  • Mutating the incoming request instead of cloning it
  • Believing the transport also follows redirects, so one call equals one hop
  • Treating the transport duration as the caller-visible latency of Client.Do