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.
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:
grpc-status. Timing, debug info.-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),))
...
Client interceptors and server interceptors are separate but symmetric. Both exist for all four RPC types β unary and streaming variants.
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)],
)
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(),
)
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
RetryInfo detail from the error).
-bin suffix for binary, grpc- reserved.continuation(), or short-circuit to reject.