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. 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.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). 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, 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.