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¶
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¶
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¶
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.EOFerror fromRecvMsgorSendMsgthatIsFailureclassifies as a failure. - A clean
io.EOFreports nothing. FromSendMsg,io.EOFmeans the RPC has already completed and the real status is waiting inRecvMsg, 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.
RateLimitInterceptoris server-side only; the HTTP package'sWithRateLimithas 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.