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.

Where a unary interceptor stops seeing a streaming call A unary interceptor runs once when the stream opens and never again, so fifty thousand messages inside the stream are never charged; a stream interceptor wrapping the receive path charges each message and can end the stream when the budget runs out. One interceptor call, fifty thousand messages stream opens interceptor runs: charge 1 50,000 messages: charged nothing stream opens wrapped RecvMsg each message charged, budget enforced wrap the stream, not just the handler β€” otherwise the limit ends at the handshake ending a stream mid-flight is legitimate: return RESOURCE_EXHAUSTED with a retry hint

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

go
// 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,
        })
    }
}
Position of the limiter in the interceptor chain Recovery and logging run outermost, authentication next so an identity exists, then the rate limiter, then tracing and the business handler; placing the limiter before authentication collapses every caller into one bucket. Order the chain so identity exists first recovery + logs authentication rate limiter tracing handler swap these two and every caller shares one bucket the limiter is also the cheapest rejection point: no tracing span, no handler allocation

Gotchas and edge cases

  • UNAVAILABLE turns 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_EXCEEDED and 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/Check and 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.
Load produced by two status codes under the same limit Rejecting with RESOURCE_EXHAUSTED yields one rejected call per attempt because clients back off, while rejecting with UNAVAILABLE triggers automatic client retries and multiplies the rejected traffic several times over. The wrong code multiplies the traffic you rejected RESOURCE_EXHAUSTED client backs off 1 rejected call per attempt UNAVAILABLE client retries at once 4 rejected calls per attempt a limiter that returns the transient code becomes a load generator

Verification and testing

bash
# 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 5000

Test 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.