Skip to content

HTTP server middleware reference

Package gitlab.com/phpboyscout/go/transit/http, imported here as transithttp because its basename collides with net/http.

Everything on this page wraps an http.Handler. The client half — retry, the circuit breaker, credential injection — is on the HTTP client middleware page.

Middleware and Chain

type Middleware func(http.Handler) http.Handler
Function Behaviour
NewChain(mw ...Middleware) Chain Builds a chain. Nil entries are silently skipped, so an if enabled branch can yield nil without a guard.
Chain.Append(mw ...Middleware) Chain Returns a new chain; the receiver is unchanged. Nil entries skipped.
Chain.Extend(other Chain) Chain Returns a new chain running the receiver's middleware first, then other's.
Chain.Then(h http.Handler) http.Handler Applies the chain. A nil handler becomes http.DefaultServeMux rather than panicking.
Chain.ThenFunc(fn http.HandlerFunc) http.Handler Then(http.HandlerFunc(fn)).

NewChain(a, b, c).Then(h) produces a(b(c(h))): the first entry is the outermost wrapper, so it sees the request first and the response last.

Chain is a value type with an unexported slice field. Copying a Chain copies the header of that slice, but every mutator returns a new chain rather than writing through it, so a copied chain can never be changed by its original.

LoggingMiddleware — one record per completed request

func LoggingMiddleware(l *slog.Logger, opts ...LoggingOption) Middleware

Logs after the inner handler returns. It never logs the request body and never logs a response body.

Fields the structured format emits

Field Always present Note
method, path, status, bytes yes path is r.URL.Path — the query string is not logged.
latency unless WithoutLatency() Rendered by time.Duration.String(), e.g. "1.2ms".
client_ip yes See client IP derivation.
user_agent unless WithoutUserAgent()
trace_id, span_id only when the request context carries a valid span Requires OTelMiddleware to sit outside this middleware.
one field per name given to WithHeaderFields only when the header is present and non-empty Key is the lowercased header name.

status is 200 when the handler writes a body without calling WriteHeader, and stays at the first code written if the handler calls WriteHeader more than once.

Options

Option Default Effect
WithFormat(LogFormat) FormatStructured FormatStructured, FormatCommon, FormatCombined or FormatJSON.
WithLogLevel(slog.Level) slog.LevelInfo The level for responses below 500. A status of 500 or above always logs at slog.LevelError and this option cannot change that.
WithoutLatency() latency logged Drops the latency field.
WithoutUserAgent() user agent logged Drops user_agent; in FormatCombined the field becomes -.
WithPathFilter(paths ...string) nothing filtered Skips logging for these paths.
WithHeaderFields(names ...string) no headers logged Copies request header values into the record.
WithTrustedProxy() off Derives client_ip from proxy headers.

Options accumulate: WithPathFilter and WithHeaderFields may be passed more than once and their arguments are unioned. The others are last-write-wins.

What the four log formats emit

Format Output
FormatStructured The slog record "request completed" with the fields above attached as key-values. This is the default.
FormatCommon NCSA Common Log Format as the record's message: <ip> - - [<time>] "<method> <path> <proto>" <status> <bytes>.
FormatCombined Common Log Format plus "<referer>" "<user-agent>", with - for either when absent.
FormatJSON A single JSON object, marshalled by transit and passed as the record's message string.

The three non-structured formats put the whole line in the message and attach no key-values, so a JSON slog handler wraps them: you get a JSON envelope whose msg is a CLF line. Pick FormatCommon/FormatCombined when a log shipper expects Apache-style access logs, and give that logger a plain text handler.

FormatJSON also does its own json.Marshal. If that fails, the request log for that request is replaced by an error record "failed to marshal request log" and the request itself is unaffected.

An unrecognised LogFormat value logs nothing

LogFormat is an int. WithFormat(LogFormat(99)) — or any value outside the four constants — matches no branch, and the request is silently not logged at all. The middleware still runs and the request is still served. Always pass one of the four named constants.

Which paths WithPathFilter actually filters

The filter is an exact, case-sensitive match on r.URL.Path. There is no prefix matching, no glob and no regex. WithPathFilter("/healthz") suppresses /healthz and /healthz?probe=1 — the query string is not part of URL.Path — but leaves /healthz/, /Healthz and /api/healthz logged.

A filtered request is passed straight through: no timing, no record, and no trace_id correlation for that path.

Which headers are redacted when logged

Naming a header in WithHeaderFields does not guarantee its value is logged. Values are checked against redact.IsSensitiveHeaderKey from go/redact and replaced with [REDACTED] when it matches. That check is two rules, not one list:

  1. An exact, case-insensitive name match against redact.SensitiveHeaderKeys: Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-API-Key, X-API-Token, X-Auth-Token, X-Access-Token, X-CSRF-Token, X-Session-Token.
  2. A fuzzy pattern over the lowercased name — any whole word auth, token, key, secret, bearer, password or credential, or the substring authorization.

Rule 2 is the one that surprises people. X-Tenant-Key, X-Custom-Auth and X-User-Password are all redacted even though none of them is on the list. X-Request-Id is not, because id is not one of the words.

Values that survive redaction are truncated to 256 bytes. That cap is a package constant and is not configurable. A header that is absent, or present but empty, is omitted from the record entirely rather than logged as an empty string.

How the client IP is derived

By default client_ip is the host part of r.RemoteAddr — the peer of the TCP connection — with the ephemeral port stripped. X-Forwarded-For and X-Real-IP are ignored, because any direct client can set them and forge the recorded IP.

WithTrustedProxy() reverses that: the first comma-separated entry of X-Forwarded-For is used when present, then X-Real-IP, then RemoteAddr. Enable it only when a reverse proxy or load balancer in front of the server overwrites those headers. On a directly-exposed server it lets any caller choose the IP that appears in your logs.

If RemoteAddr cannot be split into host and port, the whole string is logged as-is.

What the logging wrapper does to the http.ResponseWriter

LoggingMiddleware replaces the handler's http.ResponseWriter with a wrapper that counts bytes and records the status. The wrapper forwards:

Interface Forwarded how
http.Flusher Flush() delegates to the underlying writer.
interface{ FlushError() error } FlushError() returns the underlying writer's own error, so a flush failure on a broken connection is reported rather than swallowed.
http.Hijacker Hijack() delegates, or returns an error when the underlying writer is not a http.Hijacker.
everything else Unwrap() http.ResponseWriter, which http.NewResponseController follows.

The consequence for handler code: type-asserting the http.ResponseWriter directly will fail for any optional interface not in that table — io.ReaderFrom and http.Pusher among them. Reach for http.NewResponseController(w) instead, which follows Unwrap; use it for Flush, SetReadDeadline and SetWriteDeadline.

OTelMiddleware — a server span per request

func OTelMiddleware(server string, opts ...otelhttp.Option) Middleware

Wraps otelhttp.NewMiddleware. server names the span operation. Records the standard http.server.* metrics alongside the span.

It reads whichever TracerProvider and MeterProvider are installed as the OpenTelemetry globals. transit installs nothing: until a provider is set up — by go/observability or by your own otel.SetTracerProvider call — this middleware is a no-op that adds a small per-request cost and produces no telemetry. That is a silent state; there is no warning log.

Put it outside LoggingMiddleware, i.e. earlier in the chain. The span lives in the request context that otelhttp passes inward, so a logging middleware wrapped around it cannot see it and emits no trace_id:

chain := transithttp.NewChain(
    transithttp.OTelMiddleware("orders"), // creates the span
    transithttp.LoggingMiddleware(log),   // reads it into the access log
)

The cost of that order is that the span, not the log, measures the outermost layer: latency then excludes whatever OTelMiddleware itself spends. That is a fraction of a millisecond, and correlated logs are worth more.

RateLimitMiddleware — token-bucket admission control

func RateLimitMiddleware(log *slog.Logger, cfg RateLimitConfig) Middleware

Admission is non-blocking. An over-limit request is rejected immediately rather than queued, because queueing ingress under a flood is how a server runs out of memory. The outbound equivalent, WithRateLimit, blocks instead — throttling your own caller is safe in a way that throttling a stranger is not.

RateLimitConfig fields

Field Type Default An invalid value becomes
RequestsPerSecond float64 50 <= 0 → 50
Burst int 100 < 1 → 100
KeyFunc func(*http.Request) string nil — one bucket shared by all traffic
MaxTrackedKeys int 8192 < 1 → 8192. Ignored entirely when KeyFunc is nil
OnLimited func(*http.Request) nil Called before the 429 is written

RequestsPerSecond is the sustained fill rate and Burst is the bucket capacity, so a burst of 100 is drainable instantly and then refills at 50 per second. The fields carry mapstructure, yaml and json tags (requests_per_second, burst, max_tracked_keys) so a service can decode them straight from its own config file. The two function fields are tagged - and can only be set in code.

Config is normalised once, when the middleware is constructed. Mutating the RateLimitConfig value afterwards has no effect; rebuild the middleware to change a limit.

What a rejected request receives

A request the limiter refuses gets, in this order: the OnLimited callback if set, a Debug-level "request rate-limited" log carrying path and key, then the response

HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff

rate limit exceeded

(The Content-Type and nosniff headers come from http.Error, which writes the body.)

Retry-After is the fixed string 1. It is not computed from the bucket's actual refill time and is not configurable. A transit HTTP client retries a 429 by default and honours that header, so a caller backs off a second and tries again.

Limiting per client rather than globally

Set KeyFunc. ClientIPKey is supplied:

cfg := transithttp.DefaultRateLimitConfig()
cfg.KeyFunc = transithttp.ClientIPKey
func ClientIPKey(r *http.Request) string

Keys on the host part of r.RemoteAddr and deliberately never reads X-Forwarded-For or X-Real-IP, even when LoggingMiddleware has been given WithTrustedProxy(). Keying a limiter on a spoofable header would let one attacker both escape their own bucket and churn the bounded key table with fabricated IPs. Behind a trusted proxy, supply your own KeyFunc that reads the header the proxy sets.

Per-key buckets live in a bounded LRU store — see the Store reference for what eviction does to a limiter's memory of a client.

What this package does not do

  • No server-side circuit breaker. WithCircuitBreaker is client middleware. There is no way to shed inbound load based on your own error rate.
  • No panic recovery, no timeout, no CORS, no compression, no request ID. These are ordinary Middleware values and compose into a Chain, but transit does not ship them.
  • No per-route configuration. A Chain applies uniformly to whatever handler it wraps. Scope a limiter to one route by wrapping that handler rather than the mux.
  • Nothing reads a config file. Every constructor takes a Go struct. Decoding YAML or environment variables into one is the calling service's job — the struct tags are there to make that a one-liner.