DEV Community

Cover image for A Custom http.RoundTripper Is the Cleanest Place for Cross-Cutting HTTP Concerns
Gabriel Anhaia
Gabriel Anhaia

Posted on

A Custom http.RoundTripper Is the Cleanest Place for Cross-Cutting HTTP Concerns


You've seen this codebase. Every outbound call is wrapped in three lines that add the auth header. Five for logging. Eight for retrying 5xx. Each call site does it slightly differently, so half forget the trace header and a third double-log on retries. The one in internal/jobs/ puts the auth header in the URL.

The fix is older than most of the code that needs it. Go's http.RoundTripper interface was put in the standard library exactly so cross-cutting HTTP concerns live in one place (at the boundary between your code and the wire) instead of being smeared across every call site.

What http.RoundTripper actually is

The interface is one method.

type RoundTripper interface {
    RoundTrip(*http.Request) (*http.Response, error)
}
Enter fullscreen mode Exit fullscreen mode

http.Client doesn't talk to the wire directly. It hands the request to a RoundTripper, which returns a response or an error. The default is http.DefaultTransport, an *http.Transport that handles connection pooling, TLS, and HTTP/2.

The trick: http.Client.Transport is just a RoundTripper field. Whatever you put there sees every outbound request and every response. That's where the seam is.

client := &http.Client{
    Transport: myCustomTransport, // RoundTripper
    Timeout:   10 * time.Second,
}
Enter fullscreen mode Exit fullscreen mode

You don't replace *http.Transport; you wrap it. The wrapper sees the request, does whatever cross-cutting thing it needs to do, then hands the request down to the inner transport (eventually http.DefaultTransport) and the response back up.

The composition pattern

The pattern is the standard middleware decorator: each layer wraps the one below it.

type RoundTripperFunc func(*http.Request) (*http.Response, error)

func (f RoundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
    return f(r)
}
Enter fullscreen mode Exit fullscreen mode

RoundTripperFunc is the function-as-interface adapter. With it, every layer is a function that takes the next RoundTripper and returns a new one.

func WithAuth(token string) func(http.RoundTripper) http.RoundTripper {
    return func(next http.RoundTripper) http.RoundTripper {
        return RoundTripperFunc(func(r *http.Request) (*http.Response, error) {
            // Clone before mutating — see "the request-mutation gotcha" below.
            r = r.Clone(r.Context())
            r.Header.Set("Authorization", "Bearer "+token)
            return next.RoundTrip(r)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

A logging layer:

func WithLogging(log *slog.Logger) func(http.RoundTripper) http.RoundTripper {
    return func(next http.RoundTripper) http.RoundTripper {
        return RoundTripperFunc(func(r *http.Request) (*http.Response, error) {
            start := time.Now()
            resp, err := next.RoundTrip(r)
            log.Info("http",
                "method", r.Method,
                "url", r.URL.Redacted(),
                "status", statusOf(resp),
                "duration_ms", time.Since(start).Milliseconds(),
                "err", err,
            )
            return resp, err
        })
    }
}

func statusOf(r *http.Response) int {
    if r == nil {
        return 0
    }
    return r.StatusCode
}
Enter fullscreen mode Exit fullscreen mode

A trace layer (W3C traceparent, manual to keep the example dependency-free; in real code, use otelhttp.NewTransport):

func WithTraceHeader() func(http.RoundTripper) http.RoundTripper {
    return func(next http.RoundTripper) http.RoundTripper {
        return RoundTripperFunc(func(r *http.Request) (*http.Response, error) {
            if tp := traceparentFromCtx(r.Context()); tp != "" {
                r = r.Clone(r.Context())
                r.Header.Set("traceparent", tp)
            }
            return next.RoundTrip(r)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

You compose them with a small helper.

type Middleware func(http.RoundTripper) http.RoundTripper

func Chain(base http.RoundTripper, mws ...Middleware) http.RoundTripper {
    rt := base
    // Apply right-to-left so the first middleware in the slice
    // is the outermost layer (the first to see a request going out).
    for i := len(mws) - 1; i >= 0; i-- {
        rt = mws[i](rt)
    }
    return rt
}
Enter fullscreen mode Exit fullscreen mode

And the http.Client ends up looking like this:

base := http.DefaultTransport // or a tuned *http.Transport
client := &http.Client{
    Transport: Chain(base,
        WithLogging(logger),       // outermost: logs everything, including retries
        WithTraceHeader(),         // injects trace ID into every attempt
        WithRetry(3),              // retries the inner call
        WithAuth("token"),         // innermost (besides base): always re-signs each retry
    ),
    Timeout: 10 * time.Second,
}
Enter fullscreen mode Exit fullscreen mode

That covers composition. The wrap order is where this either works or doesn't.

Wrap order is the actual API

The order in Chain(...) is not cosmetic. Each middleware sees the request on the way down and the response on the way up; the order changes which layer sees what.

The rule: outer layers wrap everything inner layers do, retries and failures included. Pick the order from the answer to "what should this layer see?"

A reference order that works for most services:

  1. Logging / metrics — outermost. You want to log the final outcome of a request, including all retries. If logging sits inside retry, you log every attempt. Log volume triples on a flaky day.
  2. Tracing. You want one span per logical request, with retries as child events on that span. Most OTel HTTP instrumentations do this for you when placed above retry.
  3. Retry. Retries every transient failure inside it.
  4. Auth. Inside retry. Why: tokens can expire mid-request. If a 401 triggers a retry and auth is outside retry, every retry replays the same expired token. Re-signing on each attempt also handles request-signing schemes (AWS SigV4) that need a fresh signature per attempt.
  5. Circuit breaker. Usually between retry and auth, depending on what you want broken: per-attempt or per-logical-request. Per-logical-request (above retry) is the more common choice — a tripped breaker shouldn't be retried.
  6. Base transport (*http.Transport) — innermost.

The most common wrong-order bug: putting retry outside logging. Symptom: one error log per attempt plus a final success log. Alerts misfire because your retry succeeded but the dashboard counted every failed attempt as a service error.

Close behind: putting auth outside retry. A 401 retry replays the same headers, so the second attempt fails for the same reason. You've doubled the latency budget on a request that was never going to succeed.

Three gotchas that will bite you

1. Body draining and Close

http.RoundTripper has a contract that's easy to miss. The godoc is clear: a RoundTrip implementation should not modify the request, other than consuming and closing the request body. On the response side, the caller must close the response body, and the body must be consumed (typically io.Copy into io.Discard) for the underlying connection to return to the pool.

Failure mode A: connection leak on retry. A retry middleware that calls next.RoundTrip(req), gets a 5xx, and tries again without draining and closing the response body of the failed attempt. That connection is gone from the pool until GC reaps the response. Under load you exhaust the pool and start opening new TCP connections for every request.

func WithRetry(max int) Middleware {
    return func(next http.RoundTripper) http.RoundTripper {
        return RoundTripperFunc(func(r *http.Request) (*http.Response, error) {
            var resp *http.Response
            var err error
            for attempt := 0; attempt <= max; attempt++ {
                resp, err = next.RoundTrip(r)
                if err == nil && resp.StatusCode < 500 {
                    return resp, nil
                }
                if resp != nil {
                    // Drain + close so the connection returns to the pool.
                    _, _ = io.Copy(io.Discard, resp.Body)
                    _ = resp.Body.Close()
                }
                // Backoff omitted for brevity. Use exponential + jitter.
            }
            return resp, err
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Failure mode B: replaying the request body. If the request body is a bytes.Reader or strings.Reader, http.Request already exposes a GetBody that the standard library uses to rewind on redirects. For retry, you have to do it yourself. Snapshot the body bytes once, set req.GetBody, and on each retry call req.GetBody() to get a fresh io.ReadCloser. If GetBody is nil and the body is non-nil, you can't safely retry a body-bearing request: the inner transport already consumed the bytes on the first attempt.

http.NewRequest/http.NewRequestWithContext set GetBody for you when given a *bytes.Buffer, *bytes.Reader, or *strings.Reader. Custom readers need it set explicitly.

2. Don't mutate the incoming request

The contract says don't. The reason: the caller might keep a reference to the request and retry or log it after the round trip. Mutating headers in place breaks that.

The fix is one line: r = r.Clone(r.Context()) before any header set. Clone is a deep copy of the headers and the URL but a shallow copy of the body, which is what you want, because the body is a stream you don't own.

The header-only middlewares above use this. The retry middleware above does not clone, because it doesn't mutate; it only re-issues. If you write a middleware that both retries and adds a header, clone once at the top of the function.

3. http.RoundTripper vs http.Handler — they aren't the same direction

Beginners reach for http.Handler middleware (the func(http.Handler) http.Handler pattern in chi or gorilla/mux) when they want to add cross-cutting HTTP behavior, then try to apply it to outbound calls. It doesn't fit.

  • http.Handler is server-side. It wraps the inbound request → response path of a server you're running.
  • http.RoundTripper is client-side. It wraps the outbound request → response path of a client you're using.

The patterns rhyme (both are decorators around a single method), but the request and response types and the wrapping direction differ. If a library exposes func(http.Handler) http.Handler middleware (logging, OTel, auth-validation), you cannot drop it into http.Client.Transport. You need its RoundTripper counterpart. Most well-designed observability libraries ship both (otelhttp.NewHandler server-side, otelhttp.NewTransport client-side).

Why this lives at the boundary

You could put auth, logging, retry, tracing in a wrapper around your domain client. Many codebases do; they call it apiClient.Get(...) or paymentService.Charge(...). There are two reasons it's a worse place than RoundTripper.

It only covers one client. The moment a second outbound dependency shows up (a webhook receiver, a third-party provider, the payments retry job), every cross-cutting concern has to be re-implemented for that path. The RoundTripper chain on a shared http.Client covers all of them automatically, including the ones future-you or a teammate adds.

It's the wrong layer. Auth headers, retry-on-5xx, request signing, circuit breaking, distributed-trace propagation. These are properties of making an HTTP request. They aren't properties of charging a payment. Putting them in the payment client drops protocol concerns inside a domain type, which is the kind of cross-layer leak that turns small refactors into multi-PR incidents.

In hexagonal terms, your domain port returns a Charge from a PaymentGateway. The adapter implementing that port speaks HTTP. The cross-cutting HTTP concerns live in the adapter's http.Client (at the boundary between the adapter and the wire) so the domain code and every adapter you write benefit without any of them caring how it works.

A minimal production-leaning shape

func NewClient(cfg Config) *http.Client {
    base := &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 10 * time.Second,
    }

    return &http.Client{
        Timeout: cfg.Timeout,
        Transport: Chain(base,
            WithLogging(cfg.Logger),
            WithTraceHeader(),
            WithBreaker(cfg.Breaker),
            WithRetry(cfg.MaxRetries),
            WithAuth(cfg.TokenSource),
        ),
    }
}
Enter fullscreen mode Exit fullscreen mode

That's the API surface for the cross-cutting story. Every outbound call gets logged, traced, breaker-protected, and authenticated. Call sites don't know. New middleware? Add a function and a line in Chain(...). New service? Reuse the client.

RoundTripper has been in the standard library since Go 1. The pattern is just functional middleware. Add one to your shared client and stop touching call sites.


If this was useful

Hexagonal Architecture in Go works through the same boundary thinking on the bigger pieces (adapters around databases, message brokers, third-party APIs) where a clean client-side seam stops a domain layer from absorbing protocol detail. The Complete Guide to Go Programming covers net/http, the Transport internals, and the standard-library idioms (io.ReadCloser, context propagation, GetBody) that this pattern relies on.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)