Skip to content

HTTP client middleware reference

Package gitlab.com/phpboyscout/go/transit/http, imported here as transithttp.

Everything on this page decorates an http.RoundTripper — the transport of an *http.Client, not a handler. The server half is on the HTTP server middleware page.

ClientMiddleware and ClientChain

type ClientMiddleware func(next http.RoundTripper) http.RoundTripper
Function Behaviour
NewClientChain(mw ...ClientMiddleware) ClientChain Builds a chain. The arguments are copied, so mutating the caller's slice afterwards does not affect the chain.
ClientChain.Append(mw ...ClientMiddleware) ClientChain Returns a new chain; the receiver is unchanged.
ClientChain.Then(rt http.RoundTripper) http.RoundTripper Applies the chain, first entry outermost.

ClientChain does not tolerate a nil entry

Unlike the server-side NewChain, which skips nils, NewClientChain(nil) is accepted at construction and then panics with a nil-pointer dereference inside Then. Filter conditional middleware out of the slice before you pass it:

var mws []transithttp.ClientMiddleware
if token != "" {
    mws = append(mws, transithttp.WithBearerToken(token, "api.example.com"))
}
chain := transithttp.NewClientChain(mws...)

ClientChain.Then(nil) does not substitute a default the way the server-side Chain.Then(nil) does. A non-empty chain then wraps a nil transport and panics on the first request. Pass http.DefaultTransport explicitly.

Assembling the stack in the right order

Retry belongs closest to the raw transport and the breaker outside it, so one retry-exhausted logical call counts as one breaker failure instead of four:

retrying := transithttp.NewRetryTransport(
    http.DefaultTransport,
    transithttp.DefaultRetryConfig(),
)

chain := transithttp.NewClientChain(
    transithttp.WithCircuitBreaker(log, transithttp.DefaultCircuitBreakerConfig()),
    transithttp.WithBearerToken(token, "api.example.com"),
    transithttp.WithRequestLogging(log),
)

client := &http.Client{Transport: chain.Then(retrying)}

The middleware model explains why this ordering rather than another.

NewRetryTransport — exponential backoff with full jitter

func NewRetryTransport(next http.RoundTripper, cfg RetryConfig) http.RoundTripper
func DefaultRetryConfig() RetryConfig

DefaultRetryConfig() is 3 retries, 500ms initial backoff, 30s cap, retrying 429, 502, 503 and 504.

RetryConfig fields

Field Type Default (DefaultRetryConfig) An invalid value becomes
MaxRetries int 3 < 0 → 0. This is retries after the first attempt, so 3 means up to 4 requests.
InitialBackoff time.Duration 500ms <= 0 → 500ms
MaxBackoff time.Duration 30s <= 0 → 30s; then anything below InitialBackoff is raised to InitialBackoff
RetryableStatusCodes []int {429, 502, 503, 504} nil → that same set; an explicit []int{} means "network errors only"
RetryAllMethods bool false When true, RetryableMethods is ignored
RetryableMethods []string nil → GET, HEAD, OPTIONS, PUT, DELETE Matched case-insensitively
ShouldRetry func(attempt int, resp *http.Response, err error) bool nil Replaces the method, status-code and network-error checks entirely

The config is normalised once, inside NewRetryTransport. Changing the struct you passed in afterwards changes nothing.

RetryConfig{} — the zero value — has MaxRetries: 0 and therefore never retries. That is not a bug to work around; it is the explicit "off" setting. Start from DefaultRetryConfig() and adjust.

WithRetryAllMethods() is a value method returning a modified copy:

cfg := transithttp.DefaultRetryConfig().WithRetryAllMethods()

Which requests are eligible for a retry

Four gates apply, in this order. All must pass.

  1. Attempt budget. attempt >= MaxRetries stops.
  2. The body must be rewindable. A request with a non-nil Body that is not http.NoBody and whose GetBody is nil is never retried, whatever the config says, because the first attempt has already drained the reader and a second would send an empty body. http.NewRequest sets GetBody for you when the body is a *bytes.Buffer, *bytes.Reader or *strings.Reader; for any other io.Reader it does not, and you must set GetBody yourself to get retries.
  3. The custom predicate, if you set one. A non-nil ShouldRetry replaces gate 4 entirely — method idempotency, status codes and network-error classification all stop applying. Gates 1 and 2 still apply.
  4. The built-in policy — method idempotency and outcome, described next.

The built-in retry policy

Outcome Retried?
A transport error that proves the request never reached the origin — a dial-phase *net.OpError, or any error whose text contains connection refused Yes, for every method. No work happened at the origin, so even a POST is safe to replay.
Any other transient network error — a timeout, any *net.OpError, or an error mentioning connection reset or EOF Only if the method is eligible.
A response whose status is in RetryableStatusCodes Only if the method is eligible.
Anything else, including a 500 No. A 500 is not in the default set: transit assumes the origin may have acted on the request.

"Eligible" means RetryAllMethods is set, or the method appears in RetryableMethods, or — when both are unset — the method is in the RFC 9110 idempotent set GET, HEAD, OPTIONS, PUT, DELETE. A request with an empty method string is treated as GET.

The practical consequence: a POST is not retried on a 503 by default. Opt in only when every request through that transport carries an idempotency key:

cfg := transithttp.DefaultRetryConfig()
cfg.RetryableMethods = []string{http.MethodPost} // POST only; GET is now excluded

Note that supplying RetryableMethods replaces the idempotent set rather than adding to it. List every method you want retried.

How long a retry waits

The delay before attempt n (1-based, so the first retry is n=1) is a uniform random draw from [0, min(MaxBackoff, InitialBackoff × 2^(n-1))] — full jitter, taken from crypto/rand. With the defaults the three retries wait somewhere in [0, 500ms], [0, 1s] and [0, 2s].

Because the draw starts at zero, an individual retry can fire almost immediately. That is the point of full jitter: it stops a fleet of clients that failed together from re-attempting together.

A Retry-After response header overrides the schedule. Both forms are parsed — an integer number of seconds, and an HTTP-date, which is converted to a delay from now. A positive value is used directly, clamped to MaxBackoff so a hostile or misconfigured server cannot park the client indefinitely. A malformed value, or a date in the past, is ignored and the exponential schedule applies.

The wait is interruptible: if the request context is cancelled or its deadline passes while waiting, RoundTrip returns the context error immediately.

What the retry transport does to bodies and connections

  • Each attempt gets a cloned request (req.Clone) with a fresh body from GetBody. The caller's *http.Request is never mutated, as the RoundTripper contract requires.
  • A response that is about to be retried has its body drained and closed so the connection returns to the pool.
  • The response from the final attempt is returned to the caller with its body intact, even when it is still a 503. Retry exhaustion is not an error — you get the last response, and it is your job to check the status.

WithCircuitBreaker — fail fast on a sick downstream

func WithCircuitBreaker(log *slog.Logger, cfg CircuitBreakerConfig) ClientMiddleware
func DefaultCircuitBreakerConfig() CircuitBreakerConfig
var ErrCircuitOpen = errors.New("http: circuit breaker is open")

While the breaker is open, RoundTrip returns nil, ErrCircuitOpen without touching the network. Test for it with errors.Is(err, transithttp.ErrCircuitOpen). An *http.Client wraps transport errors in *url.Error, so errors.Is — not == — is required at the call site.

CircuitBreakerConfig fields

Field Type Default An invalid value becomes
FailureThreshold int 5 < 1 → 5
Cooldown time.Duration 30s <= 0 → 30s
HalfOpenMaxRequests int 1 < 1 → 1
IsFailure func(resp *http.Response, err error) bool nil → the default classifier below
OnStateChange func(from, to CircuitState) nil Called on every transition, while the breaker's internal lock is held

The three numeric fields carry mapstructure, yaml and json tags (failure_threshold, cooldown, half_open_max_requests); the two function fields are tagged - and are code-only. MergeCircuitBreakerConfig(base, override, fields) applies just the fields flagged in a CircuitBreakerConfigOverrides struct, which is how a service layers file or flag config onto the defaults without clobbering IsFailure.

OnStateChange must not call back into the breaker

It runs synchronously inside the breaker's critical section. Calling anything that reaches the same breaker — including its State() — deadlocks the process. Increment a counter or send on a buffered channel; do the work elsewhere.

What counts as a failure

The default classifier counts a transport error, and any response with status 500 or above. Everything else — 2xx, 3xx and all of 4xx — is a success.

A 429 does not trip the breaker. Being rate-limited means "slow down", which is retry's job; a breaker that opened on 429s would turn a downstream's own protection into an outage. Supply IsFailure to change that.

IsFailure is called with exactly what next.RoundTrip returned, so resp may be nil when err is non-nil.

One breaker per middleware value, shared across every host

WithCircuitBreaker constructs a single breaker when it is called, and every request through the resulting transport shares it. There is no per-host or per-route keying. A client that talks to three services through one transport will have all three cut off when any one of them trips the count.

Give each downstream its own *http.Client — or at least its own ClientChain — when you want independent breakers.

How the breaker recovers

Closed → open after FailureThreshold consecutive failures; one success resets the count. Open → half-open on the first request after Cooldown has elapsed. In half-open, HalfOpenMaxRequests trial requests are admitted: the first success closes the breaker, any failure re-opens it and restarts the cooldown. Requests beyond the trial budget are rejected with ErrCircuitOpen.

Full state-machine detail, including what happens to an abandoned trial, is on the resilience reference.

Credential middleware: WithBearerToken and WithBasicAuth

func WithBearerToken(token string, host ...string) ClientMiddleware
func WithBasicAuth(username, password string, host ...string) ClientMiddleware

Both set the Authorization header, pinned to a single host. A request whose req.URL.Host does not match the pin is sent without the credential and a WARN naming both hosts is logged.

The pin exists because these are RoundTrippers, and a RoundTripper runs again on every redirect hop. net/http's own cross-host Authorization stripping only governs headers set on the initial request, so without the pin a redirect to an attacker-controlled host would receive your token.

Supply the host explicitly. WithBearerToken(token, "api.example.com") pins immediately and is order-independent. Calling it with no host is deprecated: the pin is then whatever host the transport happens to see first, which depends on request ordering and is not deterministic under concurrency.

Match is on URL.Host — hostname and port as written in the URL. api.example.com does not match api.example.com:443, and neither matches a bare IP address that resolves to the same server. Pin the string that appears in the URLs you actually issue.

The mismatch warning goes to slog.Default()

Neither constructor takes a logger, and the withheld-credential warning is written to the process-wide default slog logger — not to whatever logger you passed to WithRequestLogging or WithCircuitBreaker. If you have not called slog.SetDefault, it lands on stderr in Go's default text format, outside your structured log stream.

WithBasicAuth base64-encodes username + ":" + password once, at construction. Neither constructor validates or escapes its inputs; a colon in the username produces a credential the server will split in the wrong place.

WithRateLimit — throttle outbound requests

func WithRateLimit(requestsPerSecond float64) ClientMiddleware

A single token bucket at requestsPerSecond, burst 1, shared by every request through the transport. The burst is not configurable.

Unlike the server-side limiter, this one blocks rather than rejecting: it waits for a token, or returns the context's error if the request context is cancelled or its deadline passes first. Throttling your own outbound calls by making them wait is safe; making a stranger's inbound request wait is not.

Passing a non-positive rate is not clamped and is not an error. WithRateLimit(0) lets exactly one request through — the bucket's initial token — and every request after that waits forever, or fails with rate: Wait(n=1) would exceed context deadline if its context has a deadline. Validate the value before you pass it.

WithRequestLogging — debug-level outbound request logs

func WithRequestLogging(log *slog.Logger) ClientMiddleware

Emits one Debug record per outbound request: "HTTP request completed" with method, url, status and duration, or "HTTP request failed" with method, url, duration and error. Request and response headers and bodies are never logged.

The level is fixed at Debug and cannot be raised.

The full URL is logged, query string included

url is req.URL.String(). A credential, token or personal identifier passed as a query parameter will appear verbatim in the log. Nothing on this path is redacted — only the server logger consults go/redact, and it only inspects header names. Keep secrets out of query strings, or leave this middleware off for clients that cannot.

What this package does not do

  • No *http.Client constructor. transit ships transports; it does not own the client that wires them together, and has no opinion on TLS configuration, redirect policy, connection pool sizes or timeouts. That boundary is explained here.
  • No hedging, no request coalescing, no response caching. The breaker is a stability primitive, not a cache: it never stores or replays a previously-seen response.
  • No token refresh. WithBearerToken captures a string at construction. A rotating credential needs your own ClientMiddleware.
  • No retry budget across requests. MaxRetries bounds one logical call. Nothing caps the retry traffic a client generates in aggregate; that is the breaker's job.
  • No metrics. Retries, rejections and breaker transitions are not counted or exported. CircuitBreakerConfig.OnStateChange and RateLimitConfig.OnLimited are the hooks to wire your own.