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¶
| 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¶
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:
- 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. - A fuzzy pattern over the lowercased name — any whole word
auth,token,key,secret,bearer,passwordorcredential, or the substringauthorization.
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¶
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¶
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:
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.
WithCircuitBreakeris 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
Middlewarevalues and compose into aChain, but transit does not ship them. - No per-route configuration. A
Chainapplies 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.