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.