Compose HTTP middleware¶
transit's http package has two toolboxes: server middleware that wraps an
http.Handler, and client middleware that wraps an http.RoundTripper. This guide
lists what each provides and how to assemble them.
Throughout, the package is imported aliased:
For every field, default and clamping rule behind the constructors below, see the HTTP server and HTTP client reference pages.
Server middleware¶
Server middleware has type Middleware (func(http.Handler) http.Handler). Build a
chain with NewChain and apply it with Then (or ThenFunc for an
http.HandlerFunc). The chain runs outermost-first.
| Constructor | Purpose |
|---|---|
LoggingMiddleware(log, opts…) |
One structured record per request; safe client-IP extraction. |
OTelMiddleware(server, opts…) |
An OpenTelemetry server span per request. |
RateLimitMiddleware(log, cfg) |
Token-bucket admission control, optionally keyed per client. |
chain := transithttp.NewChain(
transithttp.OTelMiddleware("orders"),
transithttp.LoggingMiddleware(log),
transithttp.RateLimitMiddleware(log, transithttp.DefaultRateLimitConfig()),
)
srv := &http.Server{Handler: chain.Then(mux)}
Put OTelMiddleware before LoggingMiddleware
The span lives in the request context OTelMiddleware passes inward, so a logging
middleware wrapped around it cannot see one and emits no trace_id or span_id.
Ordering it this way costs the access log the fraction of a millisecond
OTelMiddleware itself spends, and buys correlated logs and traces.
Tuning the logger¶
LoggingMiddleware takes functional options:
WithLogLevel(slog.Level)— the level for successful requests (5xx always logs at error).WithPathFilter("/healthz", …)— paths to skip (health probes).WithFormat(FormatStructured | FormatCommon | FormatCombined | FormatJSON)— structured slog fields (the default) or Apache-style access logs. Pass one of the four named constants: any other value logs nothing at all.WithHeaderFields("x-request-id", …)— copy request headers into the record. A header whose name looks credential-bearing is redacted even if you name it explicitly, and the rule is wider than a fixed list — see which headers are redacted.WithTrustedProxy()— trustX-Forwarded-For/X-Real-IP. Off by default, so spoofed proxy headers cannot forge the logged client IP.WithoutLatency(),WithoutUserAgent()— drop those fields.
Rate limiting¶
DefaultRateLimitConfig() is a single global bucket (50 rps, burst 100). A
RateLimitConfig also supports per-key limiting bounded by MaxTrackedKeys so the
key table cannot grow without limit. To layer config sources, merge explicit overrides
onto a base with MergeRateLimitConfig.
Client middleware¶
The client half is composed of http.RoundTripper decorators. Two shapes exist:
NewRetryTransport(next, cfg)— wraps a transport with retry directly.ClientMiddleware(func(http.RoundTripper) http.RoundTripper), assembled withNewClientChain(…).Then(transport).
| Constructor | Kind | Purpose |
|---|---|---|
NewRetryTransport(next, cfg) |
transport | Exponential-backoff retry with full jitter. |
WithCircuitBreaker(log, cfg) |
ClientMiddleware |
Fail fast while a downstream is unhealthy. |
WithBearerToken(token, host…) |
ClientMiddleware |
Authorization: Bearer, pinned to the given host. |
WithBasicAuth(user, pass, host…) |
ClientMiddleware |
Authorization: Basic, host-pinned. |
WithRateLimit(rps) |
ClientMiddleware |
Throttle outbound requests. |
WithRequestLogging(log) |
ClientMiddleware |
Debug-log each outbound request. |
Assembling the client stack¶
Order is deliberate. Retry should sit closest to the raw transport, and the circuit breaker outside retry, so one retry-exhausted logical call counts as a single breaker failure rather than one per attempt:
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)}
Then applies the chain so the first middleware is the outermost wrapper: the breaker
sees the final post-retry verdict, and the bearer token is attached once per logical
call. The middleware model covers the ordering in
full.
Retry safety: idempotent methods only¶
NewRetryTransport retries a received retryable response (429/502/503/504 by default)
only for methods that are safe to replay — the RFC 9110 idempotent set
GET, HEAD, OPTIONS, PUT, DELETE. A POST (or any other non-idempotent method)
is not retried on a status code by default, so an automatic retry can never
double-submit a create/charge/enqueue side effect.
A failure that provably happened before the request reached the origin (e.g. the connection was refused — no bytes were written) is always retried, regardless of method: the origin performed no work, so replaying is safe.
Two escape hatches let a caller opt a non-idempotent request in when it carries an idempotency key:
RetryConfig{…}.WithRetryAllMethods()— retry every method.RetryConfig{RetryableMethods: []string{http.MethodPost}}— retry a specific method set.
A custom RetryConfig.ShouldRetry predicate replaces the built-in method, status-code
and network-error checks entirely, so it can implement any policy.
RetryableStatusCodes defaults to {429, 502, 503, 504} when left nil. Pass an
explicit empty slice ([]int{}) to retry network errors only and never a status code.
Behaviour change
Earlier releases retried any method on a retryable status code, which could
silently resend a non-idempotent POST. Retries are now gated on method idempotency
by default. Callers that relied on POST retries must opt in via
WithRetryAllMethods() or RetryableMethods.
Credential safety
WithBearerToken and WithBasicAuth pin the credential to a single host. Supply the
intended host explicitly — WithBearerToken(token, "api.example.com") — so the pin is
order-independent. Calling them without a host is deprecated: the credential is then
pinned to the first host the client addresses. Because the middleware is a
RoundTripper it also runs on every redirect hop, where net/http's cross-host
Authorization stripping does not apply — host-pinning stops a redirect to another
host from capturing the credential. A request whose host does not match the pin is
logged at WARN (naming both hosts) and has its credential withheld.