A wrapping http.RoundTripper times RoundTrip — what span of the outbound call does that measure?
answer
- one method, wrapped around every call
- when exactly does RoundTrip return?
- the body is a stream, not bytes
- headers parsed, payload still on the wire
- close the body to measure the drain
basics
~20 sIt 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 linestype 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
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.
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.
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.
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