Interceptors: the middleware onion

An interceptor wraps every call as one composable layer, so cross-cutting concerns — auth, tracing, metrics, the retry policy — are written once around the handler instead of copy-pasted into every method; client-side interceptors wrap the outbound call, server-side ones wrap the inbound call, and together they form a chain a request passes inward through and the response passes back outward.

Previously

We just built a retry policy — but it can't live inside each method any more than deadline-checking or auth can; all of these wrap every call, so we need the one structural pattern that lets them compose as layers.

Scene 08

Interceptors: the middleware onion

  1. Watch
  2. Try it
  3. Predict
  4. Capture
CLIENT INTERCEPTORS · wrap the OUTBOUND callyour codecalls greet()retrymetricstraceauthhandlergreet("Ada")request → inward to handlerCHAINauthontraceonmetricsonretryoneach layer wraps the outbound call — same chain, every method
What to watch for

Every call needs the same wrapping work: prove the caller is allowed (auth), open a span so it's traceable (trace), count and time it (metrics), and apply the retry budget you built last scene (retry). Copy-pasting that into all 40 methods of a service is madness. The fix is an interceptor: a single piece of code that wraps a call, runs its bit before handing the call along, and runs its bit again on the way back. Stack several of them and you get a middleware chain — an onion of layers around the real method. Watch greet("Ada") enter from outside and pass INWARD through auth → trace → metrics → retry to reach the handler, then watch the reply travel back OUTWARD through the same layers in reverse. Same layers, opposite order.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Client.intercept
one client-side interceptor wraps the OUTBOUND call
def intercept(ctx, method, req, invoker):
# runs on the way IN, before the call leaves
ctx = attach(ctx) # e.g. deadline header, token
resp = invoker(ctx, method, req) # hand to next layer
# runs on the way OUT, on the reply
observe(resp)
return resp
Server.intercept
one server-side interceptor wraps the INBOUND call
def intercept(ctx, req, handler):
# runs as the call ARRIVES, before the handler
if not allowed(ctx): # auth lives here, not in the method
return Unauthenticated
resp = handler(ctx, req) # the real greet("Ada")
# runs after the handler, before the reply leaves
record(resp)
return resp
Chain.compose
fold the enabled layers into one onion around the handler
def compose(layers, handler):
call = handler # the core
for layer in reversed(layers):
if not layer.enabled:
continue # skip: call passes straight through
call = wrap(layer, call) # outer now wraps inner
return call # the chain, outermost-first

Where this sits in Build a gRPC-style RPC framework

Scene 08 of 14, in the Reliability act — Deadlines, cancellation, retries, and the interceptor onion.. An interceptor wraps every call as one composable layer, so auth, metrics, tracing, and the retry policy are written once around the handler instead of per method.

Up next. Our chain of layers decides WHAT happens to a call — but not WHERE it goes; the moment we ask which of many backends gets the call, HTTP/2's one-connection trick turns into a trap.

All 14 scenes in Build a gRPC-style RPC framework · Every curriculum

Built with Arqly
Every scene in Build a gRPC-style RPC framework builds on the one before it.All 14 Build a gRPC-style RPC framework scenes