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¶
| 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:
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:
Which requests are eligible for a retry¶
Four gates apply, in this order. All must pass.
- Attempt budget.
attempt >= MaxRetriesstops. - The body must be rewindable. A request with a non-nil
Bodythat is nothttp.NoBodyand whoseGetBodyis 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.NewRequestsetsGetBodyfor you when the body is a*bytes.Buffer,*bytes.Readeror*strings.Reader; for any otherio.Readerit does not, and you must setGetBodyyourself to get retries. - The custom predicate, if you set one. A non-nil
ShouldRetryreplaces gate 4 entirely — method idempotency, status codes and network-error classification all stop applying. Gates 1 and 2 still apply. - 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 fromGetBody. The caller's*http.Requestis never mutated, as theRoundTrippercontract 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¶
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¶
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.Clientconstructor. 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.
WithBearerTokencaptures a string at construction. A rotating credential needs your ownClientMiddleware. - No retry budget across requests.
MaxRetriesbounds 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.OnStateChangeandRateLimitConfig.OnLimitedare the hooks to wire your own.