Skip to content

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.

import transitgrpc "gitlab.com/phpboyscout/go/transit/grpc"

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.