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. Logging goes first because it should time the entire request, including everything the inner middleware does. Rate limiting or admission control goes early so rejected work is cheap. Instrumentation that must observe the true handler outcome goes innermost.
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). 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, and the structured access loggers redact known-sensitive headers
(Authorization, Cookie, …) even when a caller explicitly asks to log them. Free-form
values that reach a log line are passed through
go/redact first. This is what lets transit
stay framework-free while still producing production-grade, safe logs.
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.