Skip to content

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:

import transithttp "gitlab.com/phpboyscout/go/transit/http"

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() — trust X-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 with NewClientChain(…).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.