πŸ§… Interceptors & Metadata

Metadata is gRPC's version of headers. Interceptors are the middleware that reads it β€” auth, logging, retries, and tracing, written once and wrapped around every call.

Metadata = Headers

Alongside the typed message, every call carries metadata: key–value pairs, exactly like HTTP headers (because on the wire they are HTTP/2 headers). Two flavors:

Naming rules: keys are lowercase. A key ending in -bin holds binary (base64 on the wire); everything else is ASCII text. Keys prefixed grpc- are reserved by the runtime.
# Client attaches leading metadata
metadata = (("authorization", "Bearer " + token),
            ("x-request-id", req_id))
resp = stub.GetUser(req, metadata=metadata, timeout=1.0)

# Server reads it off the context
def GetUser(self, request, context):
    md = dict(context.invocation_metadata())
    token = md.get("authorization", "")
    context.set_trailing_metadata((("x-served-by", HOSTNAME),))
    ...

What an Interceptor Is

An interceptor wraps a call. It runs before the handler, can inspect/modify metadata, calls through to the next layer (the real handler or the next interceptor), then runs after. It's the onion model β€” same shape as HTTP middleware.
flowchart LR A["Auth interceptor"] --> B["Logging interceptor"] B --> C["Retry interceptor"] C --> H["Handler / RPC"] H --> C2["log result"] C2 --> B2["record latency"] B2 --> A2["strip auth ctx"]

Client interceptors and server interceptors are separate but symmetric. Both exist for all four RPC types β€” unary and streaming variants.

Server Interceptor β€” Auth

Reject unauthenticated calls before they ever reach a handler:

import grpc

class AuthInterceptor(grpc.ServerInterceptor):
    def __init__(self, verify):
        self._verify = verify

    def intercept_service(self, continuation, handler_call_details):
        md = dict(handler_call_details.invocation_metadata)
        token = md.get("authorization", "").removeprefix("Bearer ")
        if not self._verify(token):
            # short-circuit: never call continuation()
            def deny(request, context):
                context.abort(grpc.StatusCode.UNAUTHENTICATED, "bad token")
            return grpc.unary_unary_rpc_method_handler(deny)
        return continuation(handler_call_details)   # pass through

server = grpc.server(
    futures.ThreadPoolExecutor(max_workers=16),
    interceptors=[AuthInterceptor(verify_jwt)],
)
Why here and not in each handler: auth, logging, and metrics are cross-cutting β€” duplicating them in every method rots fast. One interceptor covers every current and future RPC uniformly.

Client Interceptor β€” Logging & Timing

import time, grpc

class LatencyInterceptor(grpc.UnaryUnaryClientInterceptor):
    def intercept_unary_unary(self, continuation, call_details, request):
        start = time.perf_counter()
        call = continuation(call_details, request)   # the actual RPC
        dur_ms = (time.perf_counter() - start) * 1000
        log.info("%s %.1fms -> %s",
                 call_details.method, dur_ms, call.code())
        return call

channel = grpc.intercept_channel(
    grpc.insecure_channel("users:50051"),
    LatencyInterceptor(),
)

Retries via Interceptor

A client interceptor is the natural home for a retry policy β€” retry only transient codes, respect the remaining deadline, and back off with jitter:

RETRYABLE = {grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.ABORTED}

class RetryInterceptor(grpc.UnaryUnaryClientInterceptor):
    def intercept_unary_unary(self, continuation, call_details, request):
        for attempt in range(3):
            call = continuation(call_details, request)
            if call.code() not in RETRYABLE:
                return call                       # success or fatal -> done
            sleep_with_jitter(base=0.05, attempt=attempt)
        return call                                # last attempt result
Only retry idempotent calls. Retrying a non-idempotent mutation (charge card, send message) risks doubling the effect. gRPC's built-in retry config keys off method idempotency for exactly this reason β€” mark methods honestly.
Real-world: gRPC also has a declarative retry policy in the service config (JSON) β€” retryable codes, max attempts, backoff β€” handled by the runtime without custom code. Reach for a hand-written interceptor only when you need logic the declarative policy can't express (e.g. reading a RetryInfo detail from the error).

Key Takeaways