gRPC Interceptor Rate Limiting
gRPC gives you one clean hook for enforcement β the server interceptor β and one trap: a streaming call passes that hook once, at stream start, no matter how many messages follow. Getting both right is what separates a limiter that works for unary RPCs from one that also survives streaming, and it is the transport-specific half of protocol-specific throttling.
The problem in concrete numbers
A telemetry ingestion service exposes Ingest(stream Event) returns (Summary). A well-behaved agent opens one stream per hour and sends 3,600 events. A misconfigured agent opens the same stream and sends 50,000 events per second. A unary-style interceptor sees both as one call, charges one unit, and lets the second run until something else falls over.
The correct model charges the stream open as one unit and every message inside it as another, with a budget that can be exhausted mid-stream. The status code matters too: returning UNAVAILABLE instead of RESOURCE_EXHAUSTED tells every well-built client library to retry immediately, converting a limit into an amplifier.
Decision matrix: status codes and their client effects
| Status returned | What client libraries do by default | Use for |
|---|---|---|
RESOURCE_EXHAUSTED (8) |
Retry only if the service config allows it, usually with backoff | Rate limits and quotas β the correct code |
UNAVAILABLE (14) |
Retry immediately, often several times | Genuine transient failures only |
PERMISSION_DENIED (7) |
No retry | Scope or plan violations, not rate limits |
FAILED_PRECONDITION (9) |
No retry | State problems the client must fix first |
DEADLINE_EXCEEDED (4) |
Retry per the clientβs policy | Never β do not simulate a limit with a timeout |
Attach retry-after-ms (or a similar agreed key) to the trailing metadata so a client can pace its retry instead of guessing, and publish the key name β unlike HTTP, gRPC has no universal header for this.
Step-by-step implementation
// ratelimit.go β unary and stream interceptors with per-method weights.
package ratelimit
import (
"context"
"fmt"
"sync/atomic"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// Cost per call, by full method name. Anything unlisted costs 1.
var methodCost = map[string]int{
"/telemetry.Ingest/BulkImport": 25,
"/billing.Invoices/Generate": 50,
}
type Limiter interface {
// Consume returns allowed and the milliseconds until capacity returns.
Consume(ctx context.Context, key string, cost int) (bool, int64, error)
}
func exhausted(retryMs int64) error {
return status.Errorf(codes.ResourceExhausted,
"rate limit exceeded; retry in %d ms", retryMs)
}
// Unary: one decision per call, weighted by method.
func UnaryInterceptor(l Limiter) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
key, err := identity(ctx)
if err != nil {
return nil, err
}
cost := 1
if c, ok := methodCost[info.FullMethod]; ok {
cost = c
}
ok, retryMs, err := l.Consume(ctx, key, cost)
if err != nil {
return handler(ctx, req) // fail open on limiter errors for read paths
}
if !ok {
// Trailers carry the pacing hint; the code carries the semantic.
_ = grpc.SetTrailer(ctx, metadata.Pairs("retry-after-ms", fmt.Sprint(retryMs)))
return nil, exhausted(retryMs)
}
return handler(ctx, req)
}
}
// Stream: charge the open, then charge every received message.
type countingStream struct {
grpc.ServerStream
ctx context.Context
key string
l Limiter
sample int32
interval int32
}
func (s *countingStream) RecvMsg(m any) error {
if err := s.ServerStream.RecvMsg(m); err != nil {
return err
}
// Sampled roll-up: charge `interval` units every `interval` messages.
if atomic.AddInt32(&s.sample, 1)%s.interval == 0 {
ok, retryMs, err := s.l.Consume(s.ctx, s.key, int(s.interval))
if err == nil && !ok {
return exhausted(retryMs) // ends the stream mid-flight, deliberately
}
}
return nil
}
func StreamInterceptor(l Limiter) grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo,
handler grpc.StreamHandler) error {
key, err := identity(ss.Context())
if err != nil {
return err
}
if ok, retryMs, err := l.Consume(ss.Context(), key, 1); err == nil && !ok {
return exhausted(retryMs) // reject at open: cheapest possible rejection
}
return handler(srv, &countingStream{
ServerStream: ss, ctx: ss.Context(), key: key, l: l, interval: 25,
})
}
}Gotchas and edge cases
UNAVAILABLEturns a limit into a retry storm. Client libraries treat it as transient and retry, often three times, so a limited client generates more load than an unlimited one.- Client-side retry config can override your intent. A service config with
retryableStatusCodes: [RESOURCE_EXHAUSTED]and no backoff will hammer you anyway. Publish the recommended service config alongside your limits. - Deadlines interact with queuing. Delaying rather than rejecting is tempting, but a call whose deadline expires while queued fails with
DEADLINE_EXCEEDEDand looks like a server fault. Reject promptly instead of holding. - Interceptors do not see per-message data on the send side. Server-streaming responses (server to client) need back-pressure handled in the handler, not the interceptor.
- Method weights drift. A method that cost 25 units when written may cost 5 after an optimisation. Recalculate weights from observed latency quarterly.
- Reflection and health checks share the chain. Exempt
grpc.health.v1.Health/Checkand reflection services, or a limiter can take down your own liveness probes. - Per-connection multiplexing hides concurrency. HTTP/2 lets one connection carry hundreds of concurrent streams, so a connection-count limit is not a concurrency limit. Cap concurrent streams per identity.
Verification and testing
# 1. Unary: exceed the limit and confirm the code and the metadata hint.
grpcurl -H "x-api-key: grpc_test" -d '{}' api.example.com:443 telemetry.Ingest/Ping
# ERROR: Code: ResourceExhausted Message: rate limit exceeded; retry in 850 ms
# Trailers: retry-after-ms: 850
# 2. Streaming: confirm the budget is enforced INSIDE the stream, not only at open.
ghz --insecure --proto telemetry.proto --call telemetry.Ingest/Ingest \
--stream-interval 0 -n 100000 -c 1 api.example.com:443 | grep -A3 "Status code"
# expect a ResourceExhausted count > 0; a run with zero means the stream is uncharged
# 3. Health checks must never be limited.
for i in $(seq 1 5000); do grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check; done | \
grep -c SERVING
# expect 5000Test two is the one that fails on most first implementations. If a hundred thousand streamed messages produce zero rejections, the interceptor is charging the handshake only.
Frequently Asked Questions
Which gRPC status code should a rate limit return?
RESOURCE_EXHAUSTED. It is the code client libraries and service configs treat as a capacity condition rather than a transient failure. Returning UNAVAILABLE triggers automatic immediate retries in most languages, which amplifies exactly the load you were trying to shed.
How do clients learn how long to wait?
gRPC has no standard equivalent of Retry-After, so publish a metadata key β commonly retry-after-ms β and set it in the trailing metadata alongside the error. Document the key name in your API reference, because a client cannot discover it.
Is it acceptable to end a stream that is already in flight?
Yes, and it is often the only correct option. Finish with RESOURCE_EXHAUSTED and a retry hint so the client knows the stream ended for policy reasons rather than a network fault, and make sure any partial work is either committed or cleanly discarded.
Should limits be per method or per service?
Per service for the overall budget, with per-method weights for the expensive calls. A flat per-method limit multiplies the client's total budget by the number of methods, and a single service-wide count treats a cheap lookup and an expensive report generation identically.
Related
- Protocol-Specific Throttling β the parent guide on non-REST cost models.
- WebSocket Message Rate Limiting β the same mid-stream budget problem over a different transport.
- Envoy Global Rate Limit Service Setup β limiting gRPC at the mesh instead of in-process.
- Redis Counter Architecture β the shared bucket the interceptor consumes from.