Skip to content

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.Handler or a gRPC service. Logging, OpenTelemetry server spans and rate limiting live here.
  • Client middleware wraps the code that makes calls — an http.RoundTripper or 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 decorated RoundTripper.
  • 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.

  • Breaker is 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.
  • Store is a keyed token-bucket rate limiter over golang.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.