Skip to content

gRPC interceptor reference

Package gitlab.com/phpboyscout/go/transit/grpc, imported here as transitgrpc because its basename collides with google.golang.org/grpc.

The package mirrors the HTTP one: server interceptors you install on a grpc.Server, and client instrumentation you pass as dial options and client interceptors. Where the two transports differ, this page says so rather than assuming you have read the HTTP page.

Interceptor and InterceptorChain

type Interceptor struct {
    Unary  grpc.UnaryServerInterceptor
    Stream grpc.StreamServerInterceptor
}

A concern that applies to both RPC kinds ships as one Interceptor with both fields set. Either field may be nil.

Function Behaviour
NewInterceptorChain(is ...Interceptor) InterceptorChain Collects the non-nil Unary and Stream fields into two ordered slices. A nil field is skipped, so a unary-only interceptor is safe to pass.
InterceptorChain.Append(is ...Interceptor) InterceptorChain Returns a new chain; the receiver is unchanged.
InterceptorChain.ServerOptions() []grpc.ServerOption Returns grpc.ChainUnaryInterceptor(...) and grpc.ChainStreamInterceptor(...). Each option is omitted entirely when its slice is empty, so an empty chain returns an empty (nil) slice and grpc.NewServer sees no options at all.

Order matches the HTTP chain: the first interceptor is outermost, seeing the RPC first and the response last.

ServerOptions() returns options, not a server. Combine them with anything else you pass to grpc.NewServer:

chain := transitgrpc.NewInterceptorChain(
    transitgrpc.LoggingInterceptor(log),
    transitgrpc.RateLimitInterceptor(log, transitgrpc.DefaultRateLimitConfig()),
)

opts := append(chain.ServerOptions(), transitgrpc.OTelStatsHandler())
srv := grpc.NewServer(opts...)

LoggingInterceptor — one record per completed RPC

func LoggingInterceptor(l *slog.Logger, opts ...GRPCLoggingOption) Interceptor

Returns both a unary and a stream interceptor. The record is emitted after the handler returns — for a stream, that is when the whole stream has finished, so a long-lived stream produces nothing until it closes.

Fields emitted

Field Value
method The full method name, e.g. /orders.v1.Orders/Get.
code The gRPC status code as a string, e.g. OK, NotFound. An error that is not a gRPC status is reported as Unknown.
type unary or stream.
latency Unless WithoutGRPCLatency().
trace_id, span_id Only when the RPC context carries a valid span.

The message is always "rpc completed".

Options

Option Default Effect
WithGRPCLogLevel(slog.Level) slog.LevelInfo The level for RPCs that return codes.OK.
WithoutGRPCLatency() latency logged Drops the latency field.
WithGRPCPathFilter(methods ...string) nothing filtered Skips logging for these full method names.

WithGRPCPathFilter matches the full method name exactly/pkg.Service/Method, leading slash included. There is no service-level wildcard: filtering a whole service means listing each of its methods. Get the string wrong and the filter silently does nothing.

Every non-OK code logs at ERROR

Unlike the HTTP logger, which reserves ERROR for 5xx, the gRPC logger raises the level for any code other than OK. A NotFound, an InvalidArgument or a client-cancelled Canceled all produce an error-level record. On a service where "not found" is a normal answer this will dominate your error rate. There is no option to change the classification — supply your own interceptor if you need one.

There is no gRPC equivalent of WithFormat, WithHeaderFields, WithTrustedProxy or WithoutUserAgent. Metadata is never logged, and there is no access-log format: the gRPC logger emits structured records only.

RateLimitInterceptor — token-bucket admission control

func RateLimitInterceptor(log *slog.Logger, cfg RateLimitConfig) Interceptor
func DefaultRateLimitConfig() RateLimitConfig

Admission is non-blocking — an over-limit RPC is rejected, never queued.

RateLimitConfig fields

Field Type Default An invalid value becomes
RequestsPerSecond float64 50 <= 0 → 50
Burst int 100 < 1 → 100
KeyFunc func(ctx context.Context, fullMethod string) string nil — one bucket shared by all RPCs
MaxTrackedKeys int 8192 < 1 → 8192. Ignored when KeyFunc is nil
OnLimited func(ctx context.Context, fullMethod string) nil Called before the error is returned

Note the KeyFunc signature differs from the HTTP one: it receives the full method name as well as the context, so it can limit per method, per caller, or per pair.

The numeric fields carry mapstructure, yaml and json tags (requests_per_second, burst, max_tracked_keys). MergeRateLimitConfig(base, override, fields) applies only the fields flagged in a RateLimitConfigOverrides, leaving KeyFunc and OnLimited untouched.

Config is normalised once, at construction; changing the struct afterwards has no effect.

What a rejected RPC receives

status.Error(codes.ResourceExhausted, "rate limit exceeded"), plus a Debug-level "rpc rate-limited" log carrying method. There is no gRPC equivalent of the HTTP limiter's Retry-After header — nothing tells the caller how long to wait.

ResourceExhausted is chosen so that a caller running transit's own gRPC circuit breaker does not count the rejection as a downstream failure.

Both the unary and the stream interceptor draw from the same bucket. A stream costs one token at establishment and nothing per message: a client that opens one stream and sends a million messages through it is not limited at all by this interceptor.

PeerKey — limiting per calling IP

func PeerKey(ctx context.Context, _ string) string

Returns the peer address from the RPC context with the ephemeral source port stripped, so every connection from one source IP shares a bucket. An RPC with no resolvable peer returns the empty string, and all such RPCs share a single bucket under that key.

PeerKey ignores the method argument. To limit per method, or per caller-and-method, write your own:

cfg.KeyFunc = func(ctx context.Context, fullMethod string) string {
    return transitgrpc.PeerKey(ctx, fullMethod) + "|" + fullMethod
}

OpenTelemetry: OTelStatsHandler and OTelClientHandler

func OTelStatsHandler(opts ...otelgrpc.Option) grpc.ServerOption
func OTelClientHandler(opts ...otelgrpc.Option) grpc.DialOption

Both wrap the OpenTelemetry gRPC contrib instrumentation. They are stats handlers, not interceptors — that is the shape the contrib library ships and the shape the semantic conventions are defined against — so OTelStatsHandler goes to grpc.NewServer alongside the chain's options, never into NewInterceptorChain.

Produces
OTelStatsHandler A server span per RPC and the standard rpc.server.* metrics.
OTelClientHandler A client span per RPC, and injection of the trace context into the outgoing metadata using the global propagator, so a downstream server continues the same trace instead of starting a new one.

Both read the OpenTelemetry globals. Until a TracerProvider and MeterProvider are installed — by go/observability or your own otel.SetTracerProvider — they are no-ops that emit nothing and warn about nothing.

Stats handlers accumulate: grpc.StatsHandler and grpc.WithStatsHandler append to a list rather than replacing it (checked against gRPC v1.82.1, the version this module pins). Installing OTelStatsHandler() twice — once here and once in a framework layer above — gives you two handlers and duplicate spans and metrics for every RPC. Pass it once and configure it with otelgrpc.Option values.

CircuitBreakerInterceptor — fail fast on a sick server

func CircuitBreakerInterceptor(log *slog.Logger, cfg CircuitBreakerConfig) grpc.UnaryClientInterceptor
func CircuitBreakerStreamInterceptor(log *slog.Logger, cfg CircuitBreakerConfig) grpc.StreamClientInterceptor
func DefaultCircuitBreakerConfig() CircuitBreakerConfig

These are client interceptors. Install them on the connection you dial, with grpc.WithChainUnaryInterceptor and grpc.WithChainStreamInterceptor.

CircuitBreakerConfig fields

Field Type Default An invalid value becomes
FailureThreshold int 5 < 1 → 5
Cooldown time.Duration 30s <= 0 → 30s
HalfOpenMaxRequests int 1 < 1 → 1
IsFailure func(err error) bool nil → the classifier below
OnStateChange func(from, to CircuitState) nil Called on every transition, while the breaker's internal lock is held — it must not call back into the breaker

Note IsFailure takes only an error here; the HTTP version also receives the response. MergeCircuitBreakerConfig works the same way as the HTTP one.

What counts as a failure

Only codes.Unavailable and codes.DeadlineExceeded. Every other code, including ResourceExhausted, Internal and Unknown, is treated as a success.

That is narrower than the HTTP classifier, which counts every 5xx. Internal usually means one request hit a bug rather than the server being down, and ResourceExhausted is the gRPC analogue of a 429 — counting it would let a server's own rate limiter trip its callers' breakers. Supply IsFailure if your downstream signals unhealthiness some other way.

What an open breaker returns

status.Error(codes.Unavailable, "circuit breaker is open"), chosen so the rejection is indistinguishable on the wire from the outage it stands in for.

Unlike the HTTP package's exported ErrCircuitOpen, this value is not exported, so there is no sentinel to compare against. Detect it by code and message:

if s, ok := status.FromError(err); ok &&
    s.Code() == codes.Unavailable && s.Message() == "circuit breaker is open" {
    // local rejection, the RPC never left the process
}

The code alone cannot tell you whether the breaker rejected the call or the server really was unavailable — by design.

The unary and stream interceptors do not share a breaker

CircuitBreakerInterceptor and CircuitBreakerStreamInterceptor each construct their own breaker. Installing both on one connection gives you two independent state machines: five failed unary calls will not stop a stream from being opened, and vice versa. Each needs its own FailureThreshold consecutive failures to trip.

How a stream is judged

For a unary call the verdict is simply whether IsFailure matches the returned error.

A stream is different, because it may outlive the call that opened it:

  • Successful establishment is the verdict, reported immediately. A half-open trial therefore releases its slot and closes the breaker as soon as the stream opens, rather than holding the trial for the stream's lifetime.
  • Establishment failure is the terminal verdict for that call.
  • Per-message failures afterwards are reported out of band and count against the now-closed breaker's consecutive-failure counter, so a stream that fails mid-flight can still re-trip it. At most one such report is made per stream — the first non-io.EOF error from RecvMsg or SendMsg that IsFailure classifies as a failure.
  • A clean io.EOF reports nothing. From SendMsg, io.EOF means the RPC has already completed and the real status is waiting in RecvMsg, so it is not counted either.

CircuitState

type CircuitState int

const (
    StateClosed   = CircuitState(resilience.StateClosed)
    StateOpen     = CircuitState(resilience.StateOpen)
    StateHalfOpen = CircuitState(resilience.StateHalfOpen)
)

String() renders "closed", "open", "half-open", or "unknown" for any other value. The constants are derived from the resilience package's so the two enumerations cannot drift apart. http.CircuitState is a separate type with the same underlying values — they are not interchangeable without a conversion.

There is no accessor for the current state from outside: OnStateChange is the only way to observe transitions.

What this package does not do

  • No client retry. There is no gRPC counterpart to NewRetryTransport. gRPC has retry built into the SDK, configured through a service config policy on the connection (grpc.WithDefaultServiceConfig), so transit does not duplicate it. The consequence worth knowing: the ordering advice for HTTP — breaker outside retry — does not transfer, because SDK retry happens inside the interceptor chain, so a retried call is one breaker verdict either way.
  • No client-side rate limiter. RateLimitInterceptor is server-side only; the HTTP package's WithRateLimit has no gRPC twin.
  • No credential injection. Use grpc.WithPerRPCCredentials, which the gRPC SDK gates on transport security for you.
  • No server-side circuit breaker, no panic recovery, no timeouts. These are ordinary interceptors and compose into an InterceptorChain, but transit does not ship them.
  • No metadata logging or redaction. The gRPC logger never touches metadata, so the header-redaction machinery on the HTTP side has no counterpart here.