Interceptors¶
An ordinary gRPC interceptor wraps only the moment a handler is invoked —
for a response-streaming RPC that tells you nothing about how long the
stream actually ran, and any cleanup scheduled after the wrapped call fires
before the client has read a single message. AsyncServerInterceptor
(grpc_server_kit.aio.interceptors) closes that gap: a subclass implements
one async-generator method, around_call, and the base class turns it into
a context manager spanning the entire RPC — including full consumption
of a streamed response — for all four gRPC call kinds.
The around_call hook¶
from collections.abc import AsyncIterator
from grpc_server_kit.aio.interceptors import AsyncServerInterceptor, RpcCall
class MyInterceptor(AsyncServerInterceptor):
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
... # setup — runs before the handler
yield
... # teardown — runs after the handler (or the full stream) completes
RpcCall is the frozen view of one RPC: method_name (e.g.
"/pkg.Service/Method"), request (the request message, or an async
iterator of them for streaming requests), context
(grpc.aio.ServicerContext), and the request_streaming /
response_streaming flags. One implementation covers unary-unary,
unary-stream, stream-unary, and stream-stream alike — intercept_service
inspects the resolved grpc.RpcMethodHandler and dispatches internally to
unary or streaming wiring, but both drive the exact same around_call
generator, so a subclass never branches on call kind unless its own logic
needs to.
Streaming coverage¶
For a response-streaming call, the code inside the context manager built
from around_call iterates the handler's async generator to completion
(async for response in result: yield response) before control passes back
out through your yield. On early termination — the client disconnects, the
consumer breaks, or an exception propagates — a finally block closes the
inner generator immediately, so the handler's own finally blocks and any
DI-scoped teardown run inside the RPC's async context, not later under
the event loop's async-generator finalizer, which offers no timing guarantee
at all. Practically: durations AsyncMetricsInterceptor records and spans
AsyncTracingInterceptor opens cover the full stream lifetime, not just
time-to-first-message, and an exception raised at message 500 of a stream is
caught by the same try/except around your yield as one raised before
message 1.
skip_methods¶
A constructor kwarg — a Collection[str] of full method names such as
"/grpc.health.v1.Health/Check". A method in this set is not merely
fast-pathed inside the wrapper: intercept_service returns gRPC's original,
unwrapped handler for it, so interception has zero overhead for skipped
methods.
The four observability interceptors default skip_methods to the gRPC
Health Check/Watch methods, so routine liveness/readiness polling
doesn't inflate request logs, latency histograms, or trace volume — see
Observability.
The per-method handler cache¶
intercept_service runs on every incoming RPC, not once at startup, so
re-deriving a wrapped handler on each call would be wasted work. Each
interceptor instance caches (source_handler, wrapped_handler) per method
name; a cache hit requires the handler continuation() just resolved to be
the same object (is, not ==) as the one that produced the cached
entry. Registered servicers hand back stable handler objects, so the cache
is effectively permanent for them; for dynamic/generic handlers whose
resolution can swap the underlying implementation between calls, the
identity check detects the change and rewraps instead of serving a stale
wrapper.
context.abort() and AbortError¶
grpc.aio.ServicerContext.abort() raises grpc.aio.AbortError — a plain
Exception subclass that is not a grpc.RpcError subclass. Two
consequences for a custom interceptor written around a yield:
except grpc.RpcError:does not catch a deliberate abort. To treat "the handler (or an inner interceptor) already decided the outcome" differently from "something broke," add an explicitexcept grpc.aio.AbortError:.except Exception:does catch it. A catch-all that logs and re-raises every exception will also fire for every deliberate abort unless it filtersAbortErrorout first.
Every shipped interceptor that wraps exceptions follows the same shape, narrowest first:
try:
yield
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
raise
except grpc.aio.AbortError:
raise # deliberate — the context already carries the real status
except grpc.RpcError:
raise # already a proper gRPC error
except Exception as exc:
... # genuinely unexpected — map it, log it, capture it
The canonical chain¶
The six shipped interceptors, outermost first:
| # | Interceptor | What it does |
|---|---|---|
| 1 | AsyncMetricsInterceptor |
Records request count and duration through any GrpcServerMetricsProtocol; outermost so its timer covers every interceptor below it too |
| 2 | AsyncContextInterceptor |
Binds configured headers (e.g. x-request-id) to context vars / structlog before anything else logs or traces |
| 3 | AsyncRequestLoggerInterceptor |
Logs start, completion, and failure at a severity matched to the resulting status |
| 4 | AsyncTracingInterceptor |
Opens one OTel-semconv span per RPC through any TracerProtocol |
| 5 | AsyncExceptionHandlerInterceptor |
Maps exception types to grpc.StatusCode over the MRO; aborts with a safe, non-leaking detail |
| 6 | AsyncSentryInterceptor |
Reports unexpected exceptions through any ErrorReporterProtocol, inside an isolated per-RPC scope |
Sentry is placed inside (closer to the handler than) the exception
handler on purpose. An exception raised by the handler propagates from the
innermost interceptor outward, so Sentry — innermost — sees the raw
exception first and can capture it, then re-raises it unchanged. The
exception handler, one level out, catches that same raw exception and
converts it into a grpc.aio.AbortError. If Sentry sat outside the
exception handler instead, every exception reaching it would already be an
AbortError — which Sentry explicitly never captures — so it would report
nothing, ever. See Observability for the
capture-filter mechanics.
Writing a custom interceptor¶
A concurrency limiter: reject a method with RESOURCE_EXHAUSTED once too many
calls to it are already in flight.
from collections.abc import AsyncIterator
import grpc
from grpc_server_kit.aio.interceptors import AsyncServerInterceptor, RpcCall
class ConcurrencyLimitInterceptor(AsyncServerInterceptor):
"""Reject a method with RESOURCE_EXHAUSTED past a per-method in-flight limit."""
def __init__(self, max_in_flight: int, **kwargs: object) -> None:
super().__init__(**kwargs) # forwards skip_methods
self._max_in_flight = max_in_flight
self._in_flight: dict[str, int] = {}
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
count = self._in_flight.get(call.method_name, 0)
if count >= self._max_in_flight:
await call.context.abort(
grpc.StatusCode.RESOURCE_EXHAUSTED,
f"Too many concurrent calls to {call.method_name}",
)
self._in_flight[call.method_name] = count + 1
try:
yield # the RPC — or the full response stream — runs here
finally:
self._in_flight[call.method_name] -= 1
No special handling of AbortError is needed here since this interceptor
doesn't catch exceptions around its yield — the abort just propagates
outward like any other outcome. And because the same hook wraps
stream-stream calls too, a streamed RPC stays counted as "in flight" for its
whole duration, not just until its first response, which is exactly what a
concurrency limit should do:
app.add_interceptors([
AsyncMetricsInterceptor(metrics=get_grpc_server_metrics(), service_name="my.pkg.MyService"),
ConcurrencyLimitInterceptor(max_in_flight=50),
...
])
See Advanced for wiring interceptors
through Dishka instead of app.add_interceptors.