Compose gRPC interceptors¶
transit's grpc package mirrors the HTTP package for gRPC: server interceptors you
install on a grpc.Server, and client instrumentation you pass as dial options and
client interceptors.
For every field, default and clamping rule behind the constructors below, see the gRPC reference.
Server interceptors¶
Server interceptors are grouped as an Interceptor{Unary, Stream} pair (either field
may be nil). NewInterceptorChain collects them and ServerOptions() turns the chain
into the grpc.ServerOption values that install it via ChainUnaryInterceptor /
ChainStreamInterceptor.
| Constructor | Returns | Purpose |
|---|---|---|
LoggingInterceptor(log, opts…) |
Interceptor |
One structured record per RPC. |
RateLimitInterceptor(log, cfg) |
Interceptor |
Token-bucket admission control. |
OTelStatsHandler(opts…) |
grpc.ServerOption |
OpenTelemetry server spans + rpc.server.* metrics. |
OTelStatsHandler is a stats handler, not an interceptor — that is the shape the OTel
gRPC contrib library ships — so pass it to grpc.NewServer alongside the chain's
options rather than into NewInterceptorChain:
chain := transitgrpc.NewInterceptorChain(
transitgrpc.LoggingInterceptor(log),
transitgrpc.RateLimitInterceptor(log, transitgrpc.DefaultRateLimitConfig()),
)
opts := append(chain.ServerOptions(), transitgrpc.OTelStatsHandler())
srv := grpc.NewServer(opts...)
LoggingInterceptor takes WithGRPCLogLevel, WithGRPCPathFilter and
WithoutGRPCLatency, mirroring the HTTP logger. It does not mirror its level policy:
every code other than OK logs at ERROR, including NotFound and
InvalidArgument, where the HTTP logger reserves ERROR for 5xx. On a service where
"not found" is a normal answer, expect that to dominate your error rate.
Client instrumentation¶
On the client, OpenTelemetry is a dial option and the circuit breaker is a standard gRPC client interceptor:
| Constructor | Returns | Purpose |
|---|---|---|
OTelClientHandler(opts…) |
grpc.DialOption |
Client spans + trace-context propagation. |
CircuitBreakerInterceptor(log, cfg) |
grpc.UnaryClientInterceptor |
Fail fast on a consistently failing server. |
CircuitBreakerStreamInterceptor(log, cfg) |
grpc.StreamClientInterceptor |
The streaming equivalent: successful establishment is the breaker verdict; classified per-message errors still count against it. |
conn, err := grpc.NewClient(
"dns:///orders.internal:443",
grpc.WithTransportCredentials(creds),
transitgrpc.OTelClientHandler(),
grpc.WithUnaryInterceptor(
transitgrpc.CircuitBreakerInterceptor(log, transitgrpc.DefaultCircuitBreakerConfig()),
),
)
OTelClientHandler injects the trace context into outgoing metadata using the global
propagator, so a downstream gRPC server continues the same trace rather than starting a
new one. Like the server handler, it reads the globally-installed providers and is a
noop until those are set up (see
go/observability).
The unary and stream breakers are two separate breakers
CircuitBreakerInterceptor and CircuitBreakerStreamInterceptor each build their own
state machine. Installing both on one connection means unary failures never trip the
stream breaker and vice versa — each needs its own run of FailureThreshold
consecutive failures.
There is no gRPC counterpart to the HTTP NewRetryTransport: gRPC implements retry in the
SDK, as a service config policy set with grpc.WithDefaultServiceConfig. Because that
retry happens beneath the interceptor chain, the HTTP rule about placing the breaker
outside retry does not apply here — a retried call reaches the breaker as one verdict
whatever the interceptor order.
Config merging¶
Both CircuitBreakerConfig and RateLimitConfig have Merge…Config helpers that apply
only explicitly-flagged override fields onto a base, leaving function fields (custom
failure predicates, key extractors) under your control. This is how a service layers
file/env/flag config onto the defaults without a field-by-field copy.