Skip to content

Compose client middleware

httpclient provides the hardened *http.Client; the per-request behaviour — retry, circuit breaking, credentials, logging — comes from go/transit's client round-trippers. This guide shows how the two fit together and in what order.

import (
    "gitlab.com/phpboyscout/go/httpclient"
    transithttp "gitlab.com/phpboyscout/go/transit/http"
)

Which option to use for which middleware

NewClient takes two middleware-related options:

Option Wraps Use for
WithRetry(cfg) the transport directly, closest to the wire automatic retry of transient failures
WithClientMiddleware(chain) the transport outside retry everything else: circuit breaker, auth, logging, rate limit

Both take go/transit types. Retry is applied closest to the raw transport; the ClientChain wraps the result, so a chain member (a circuit breaker, a logger) sees one logical call rather than each retry attempt.

Neither is applied unless you pass it — an unconfigured client has no retry and no chain.

A full client stack

client := httpclient.NewClient(
    httpclient.WithTimeout(2*time.Minute),

    // retry sits closest to the transport
    httpclient.WithRetry(transithttp.DefaultRetryConfig()),

    // the chain wraps retry — breaker outermost so it sees the post-retry verdict
    httpclient.WithClientMiddleware(transithttp.NewClientChain(
        transithttp.WithCircuitBreaker(log, transithttp.DefaultCircuitBreakerConfig()),
        transithttp.WithBearerToken(token, "api.example.com"),
        transithttp.WithRequestLogging(log),
    )),
)

This produces the layering breaker → auth → logging → retry → transport. The transit middleware model explains why the breaker belongs outside retry.

Set the timeout to cover the retries you asked for. Client.Timeout bounds the whole exchange including backoff waits, and the 30-second default is shorter than a single maximum backoff from DefaultRetryConfig() — see WithTimeout.

Which middleware go/transit provides

From go/transit/http, for use inside NewClientChain:

  • WithCircuitBreaker(log, cfg) — fail fast while a downstream is unhealthy. Defaults: five consecutive failures to open, 30-second cooldown, one trial request. Transport errors and 5xx count as failures; a 429 does not.
  • WithBearerToken(token, host) / WithBasicAuth(user, pass, host) — credentials pinned to the host you name, so a redirect to anywhere else has the credential withheld and a warning logged. The host argument is variadic and omitting it is deprecated: the credential then pins to whichever host the client addresses first.
  • WithRateLimit(rps) — throttle outbound requests. A token bucket with a burst of 1, shared across every request through the transport; it blocks until a token frees up or the request context is cancelled.
  • WithRequestLogging(log) — log method, URL, status and duration of each request at debug level. Headers and bodies are never logged.

Add a middleware transit does not have

A chain member is just a func(http.RoundTripper) http.RoundTripper, so anything shaped like a round-tripper composes without giving up the hardened transport:

httpclient.WithClientMiddleware(transithttp.NewClientChain(
    func(next http.RoundTripper) http.RoundTripper {
        return otelhttp.NewTransport(next)
    },
))

That is the route for client-side tracing, a request recorder, or a header injector. Reaching for WithTransport instead would replace the hardened transport rather than wrap it.

Bring your own transport

WithTransport(rt) replaces the hardened transport entirely — retry and the chain still wrap it, and so do the timeout and redirect policy, but the TLS config, proxy support, connection limits and dial timeouts go with it. WithTLSConfig and WithCertPool are silently ignored once a transport is supplied, because they configure a transport that is never built.

To keep the hardening and change one field, build the transport first:

tr := httpclient.NewTransport(nil)
tr.MaxIdleConnsPerHost = 100

client := httpclient.NewClient(httpclient.WithTransport(tr))

Every field NewTransport sets, and every one it leaves alone, is listed in defaults and limits. The reasoning behind the split between this factory and the transit middleware is in hardened defaults.