The middleware model¶
transit is organised around a few consistent ideas that repeat across both transports. Understanding them makes the API predictable: once you know how a chain composes and where a concern belongs, the HTTP and gRPC packages read the same way.
Chains compose outermost-first¶
Both packages build an ordered chain and apply it to a target. For HTTP,
NewChain(a, b, c).Then(handler) produces a(b(c(handler))) — a is the outermost
wrapper, so on the way in it runs first and on the way out it runs last. For gRPC,
NewInterceptorChain(…).ServerOptions() produces the equivalent ChainUnaryInterceptor
/ ChainStreamInterceptor options.
The practical consequence is ordering by responsibility. Anything that produces context for the layers beneath it goes early. Rate limiting and admission control go early too, so rejected work is cheap. Instrumentation that must observe the true handler outcome goes innermost.
Should logging or tracing go first?¶
Tracing. OTelMiddleware should sit outside LoggingMiddleware, i.e. earlier in the
chain — which is the opposite of the intuition that says logging should be outermost so it
times everything.
The reason is that a chain passes information inward, never outward. OTelMiddleware
starts a span and puts it in the request context it hands to the next handler. Middleware
wrapped around it received the original context, before the span existed, and there is no
mechanism by which the span can propagate back out. So logging placed outermost sees no
span, and the access log has no trace_id — silently, with no error and no warning. It
just quietly stops being correlatable.
What you give up by ordering it the other way is the time OTelMiddleware itself spends,
which is excluded from the logged latency. That is a fraction of a millisecond of span
bookkeeping. A log line that can be joined to its trace is worth considerably more than
measuring it.
The same reasoning explains why the rate limiter goes after logging rather than before, despite rejected work being cheaper the earlier it is refused: put the limiter outermost and the 429s it returns never reach the access log at all, so the one thing you most want evidence of becomes invisible.
Two halves: server and client¶
Every concern exists on the side of the wire where it makes sense, and transit ships both:
- Server middleware wraps the code that accepts calls — an
http.Handleror a gRPC service. Logging, OpenTelemetry server spans and rate limiting live here. - Client middleware wraps the code that makes calls — an
http.RoundTripperor a gRPC client connection. Retry, the circuit breaker, credential injection and client spans live here.
This is why the same module is imported by both a service and the clients it calls: a
gRPC server installs LoggingInterceptor, while a service calling it installs
OTelClientHandler and CircuitBreakerInterceptor. One implementation, two roles.
Where retry and the breaker belong¶
Two client concerns interact, and their order is not arbitrary:
- Retry sits closest to the raw transport. It resends transient failures, so it must
wrap the transport that actually performs the call.
NewRetryTransport(next, cfg)returns exactly that — a decoratedRoundTripper. - The circuit breaker sits outside retry. It should see one logical call — the final, post-retry verdict — as a single outcome. If the breaker were inside retry, a single failing call that retries three times would register three failures and trip the breaker far too eagerly.
So the client stack reads breaker → (auth, logging) → retry → transport, assembled as
NewClientChain(WithCircuitBreaker(…), …).Then(NewRetryTransport(transport, …)).
Resilience is a primitive layer¶
The circuit breaker and rate limiter are not HTTP- or gRPC-specific; they are in the
resilience package, and the transport layers wrap them.
Breakeris a three-state machine: closed (admit all calls, count consecutive failures), open (reject fast until the cooldown elapses), half-open (admit a bounded number of trial calls; the first success closes it, any failure re-opens it). A half-open trial that produces no verdict within a further cooldown is treated as expired and the breaker re-opens, so an abandoned trial can never wedge it half-open forever. The defaults are five failures to trip, a 30-second cooldown, and one half-open trial.Storeis a keyed token-bucket rate limiter overgolang.org/x/time/rate, bounded by a maximum tracked-key count so a per-client limiter cannot grow its key table without limit.
Keeping these transport-neutral means the HTTP and gRPC circuit breakers share one tested state machine, and their behaviour cannot drift apart.
*slog.Logger at every seam¶
No middleware here depends on a logging framework. Anything that logs takes a plain
*slog.Logger — the standard library's interface, with no adapter and no wrapper — so a
service brings its own handler and transit has an opinion about none of it. That is what
lets the module stay framework-free while still producing structured access logs.
The one thing it does not leave to the caller is header redaction. Header names named in
WithHeaderFields are checked against go/redact and
their values replaced with [REDACTED] when they look credential-bearing, even when the
caller explicitly asked for them. Sharing that catalogue with the rest of the toolkit means
a header added to it protects every consumer at once, rather than each one maintaining its
own list.
What transit does not do is redact by content. Nothing scans a value for something that looks like a token: the check is on the header's name, and it is the only redaction in the module. Request paths, user agents, referers and — on the client side — full request URLs including their query strings are logged verbatim. Keeping secrets out of those is the caller's job.
The client-constructor boundary¶
transit provides the transport primitives — a retrying RoundTripper, a client
interceptor — but it deliberately does not own the secure HTTP client constructor
that wires them into a configured *http.Client. That constructor, with its TLS
defaults and redirect policy, lives one layer up (in go-tool-base today, and a dedicated
light-client module in future). The boundary keeps transit focused on behaviour that is
identical for every caller, and leaves connection policy — which is a service-level
decision — to the layer that owns it.
Where the model stops¶
The ideas above describe how transit composes. They say nothing about what it refuses to do, and several of those refusals shape a design more than the composition rules: per-process rather than distributed state, no configuration loading, no gRPC retry, no metrics. Those are set out in What transit does not do.
The other half of the reasoning is the defaults themselves — why a POST is not retried,
why proxy headers are distrusted, why a 429 does not open the breaker.
Why the defaults are what they are covers each.