What transit does not do¶
transit is a small module with hard edges. Several things people reasonably expect from "transport middleware" are absent on purpose, and knowing which is faster than discovering it in a code review.
Each reference page carries a "What this package does not do" list for its own surface — HTTP server, HTTP client, gRPC, resilience. This page covers the boundaries that apply to the module as a whole, and says why each one is where it is.
Can I use transit without a service framework?¶
Yes, and that is the point. Every constructor takes standard library types — an
*slog.Logger, an http.Handler, an http.RoundTripper — or gRPC SDK types. Nothing
here requires go-tool-base, a config framework, a DI container or a particular logger.
A depfootprint_test.go guard enforces it: go-tool-base, Viper, Cobra, pflag, Charm and
the AWS, Google Cloud and Azure SDKs are forbidden anywhere in the dependency graph, and
the build fails if one reappears. What transit does carry is the gRPC SDK, the
OpenTelemetry gRPC and HTTP contrib instrumentation, golang.org/x/time/rate,
go/redact and cockroachdb/errors — carrying those is
the middleware's job, not a leak.
Does transit read my configuration file?¶
No. Every constructor takes a Go struct and nothing else. There is no file loader, no environment-variable binding, no flag registration, and no hot reload.
What transit does provide is the shape to decode into. RateLimitConfig and
CircuitBreakerConfig carry mapstructure, yaml and json tags on their numeric
fields, so a service can unmarshal its own config straight onto one. The function fields —
KeyFunc, IsFailure, OnLimited, OnStateChange — are tagged - and can only be set
in code, which is deliberate: a config file should not be able to name a failure
classifier.
Merge…Config closes the gap between the two. It applies only the fields an adapter marks
as explicitly supplied, so a partial config file overrides three integers without silently
resetting the function fields to nil.
Can I change a limit while the service is running?¶
No. Every config is normalised once, when the middleware or interceptor is constructed. Mutating the struct you passed in afterwards has no effect, because the middleware closed over the normalised copy.
Changing a limit means building new middleware and swapping the handler or transport that uses it — which is the calling service's business, not transit's. Nothing here is designed to be reconfigured in place, and nothing is guarded for it.
Does the circuit breaker or rate limiter coordinate across replicas?¶
No. Both are in-memory and per-process, with no shared backend.
The consequences are worth stating plainly, because they change what a number means:
- A rate limit of 50 rps across ten replicas admits up to 500 rps in aggregate. It is an admission ceiling for one process, not a service quota.
- A circuit breaker trips per process. Ten replicas each need their own run of consecutive failures, so a downstream outage produces a ragged rather than a clean cut-off.
- All of it is lost on restart. A process restarting into a downed dependency starts closed and has to trip again.
A distributed limiter is a different kind of component — it needs a store, a clock agreement and a failure mode for when the store is unreachable. That does not belong in a framework-free middleware module, and pretending otherwise would be worse than the gap.
Why is there no retry for gRPC?¶
Because gRPC already has one. The SDK implements retry as a service-config policy on the
connection, with its own backoff, retryable-code set and per-attempt deadlines, configured
through grpc.WithDefaultServiceConfig. Adding a second, interceptor-level retry would
give you two independent budgets multiplying together.
The asymmetry that follows: the ordering advice for HTTP does not carry over to gRPC. On the HTTP side you must place the breaker outside retry, because retry is a transport decorator and a breaker inside it would count every attempt. On the gRPC side SDK retry happens beneath the interceptor chain, so a retried call reaches the breaker as a single verdict no matter how the interceptors are ordered.
Can I put a circuit breaker on my server?¶
No — both circuit breakers are client-side. There is no inbound load shedding based on your own error rate or saturation, and no bulkhead or concurrency limiter.
The rate limiters are the only inbound protection transit ships, and they are blind admission control: a fixed rate and burst, with no feedback from how the server is actually coping.
Does transit emit metrics for retries, rejections or breaker trips?¶
No. The only telemetry it produces is what the OpenTelemetry instrumentation produces —
spans and the standard http.server.* and rpc.server.* metrics — plus the structured
access logs.
Nothing counts retry attempts, rate-limit rejections or breaker transitions. Three hooks exist to wire your own:
| Hook | Fires when |
|---|---|
CircuitBreakerConfig.OnStateChange |
The breaker changes state. Runs under the breaker's lock — do not block in it. |
RateLimitConfig.OnLimited |
A request or RPC is rejected, before the response is written. |
RetryConfig.ShouldRetry |
Every retry decision. Counting from here means also reimplementing the default policy, since a custom predicate replaces it. |
The absence is not an oversight so much as an unresolved question: emitting metrics means choosing a metrics API, and choosing one for a module that is meant to be framework-free is exactly the coupling the module exists to avoid. Until that is answered, the hooks are the answer.
Does transit give me a configured *http.Client?¶
No, and this is the boundary drawn most deliberately. transit provides transport
primitives — a retrying RoundTripper, a breaker decorator, credential injection. It
does not own the constructor that assembles them into a client with TLS settings, a
redirect policy, timeouts and pool sizes.
Those are connection policy, which is a service-level decision, and they belong to the layer above — go-tool-base today, a dedicated light-client module later. Keeping them out is what lets transit stay identical for every caller. The middleware model covers the same boundary from the design side.
What about middleware transit simply doesn't ship?¶
Panic recovery, request timeouts, CORS, compression, request IDs, authentication and authorization are all absent from both transports.
None of them is excluded on principle — they are ordinary Middleware or Interceptor
values and compose into a Chain or an InterceptorChain alongside transit's. They are
absent because they are either trivially available elsewhere or genuinely
application-specific, and every one that shipped here would be one more thing every
consumer carries.