Measured on 0.1.1 with grpcio 1.83.1, against an in-process server with all four RPC kinds. The interceptor is the shortest thing anyone would write with this seam, a request id on the outgoing call:
class TokenAcrossYield(AsyncAroundClientInterceptor):
async def around_call(self, call: ClientCall):
token = REQUEST_ID.set("req-42")
try:
yield
finally:
REQUEST_ID.reset(token)
--- an around_call that sets a ContextVar before the yield and resets it after
unary_unary caller got the response
unary_stream caller got ValueError: <Token ...> was created in a different Context
stream_unary caller got the response
loop exception handler: Task exception was never retrieved: ValueError("<Token ...> was created in a different Context")
stream_stream caller got ValueError: <Token ...> was created in a different Context
--- the same interceptor on a stream the server fails mid-way
unary_stream caller got UNAVAILABLE: died at item 2
loop exception handler: Task exception was never retrieved: ValueError("<Token ...>")
--- any teardown that raises, per RPC kind
unary_unary caller got RuntimeError: the teardown itself failed
unary_stream caller got RuntimeError: the teardown itself failed
stream_unary caller got the response
loop exception handler: Task exception was never retrieved: RuntimeError('the teardown itself failed')
stream_stream caller got RuntimeError: the teardown itself failed
Two things are wrong here, and they compound.
The teardown runs in a different context than the setup. For a response stream _run_rpc closes the around context from _closing_stream, in whichever task drains the stream; for a streaming request it closes it from _spawn_background. contextvars refuses a reset of a token minted in another context, so the pair that is the normal way to scope anything for the duration of a call — a request id, a correlation id, an OpenTelemetry context — cannot be written against this seam for three of the four RPC kinds. AsyncClientTracingInterceptor already knows this: its docstring says the span is made current only while the call is created because "detaching the OpenTelemetry context there fails". That warning lives on the one interceptor that hit it, while AsyncAroundClientInterceptor's own docstring, the interceptor rows of the API table and rule 13 of the agents page all describe around_call as code that runs before and after the whole RPC, with nothing about the context boundary.
A teardown failure changes the outcome of a call that already finished. The last block is the general case, with no contextvars in it: an exception from anything after the yield reaches the caller as that exception for unary-unary, unary-stream and stream-stream — a call the server answered OK is delivered to application code as RuntimeError — and for stream-unary it is swallowed into the loop's exception handler. Interceptors are observability; a metrics push or a log write that fails must not take the response with it, and the same mistake must not have four different outcomes.
What I think it needs, in this order:
- Teardown exceptions never change the call's outcome. Whatever the around generator raises after the
yield is logged at ERROR with the method on it and dropped, in all four paths — same as an after_* failure would be treated in any observability chain. An exception raised before the yield keeps its current meaning: refusing the call.
- Setup and teardown in the same context, if it can be had.
asyncio.Task takes context= since 3.11, so the finalizer could close the around context in the very contextvars.Context the setup ran in, which would make the token pair above work on every kind. If that turns out to be wrong for the streaming paths (the teardown deliberately runs in the consumer's task so a cancellation is seen), then say so instead: rule 13 and the AsyncAroundClientInterceptor docstring both need the sentence "the code after the yield may run in a different context than the code before it, so do not carry a ContextVar token or an OpenTelemetry context across the yield", plus the pattern that does work.
- A test per RPC kind for both, since this is exactly the class of bug that shows up in one kind and not the others.
Lab: probe.py and interceptors_lab.py in https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-07-grpc-interceptors-and-streams.
Measured on 0.1.1 with grpcio 1.83.1, against an in-process server with all four RPC kinds. The interceptor is the shortest thing anyone would write with this seam, a request id on the outgoing call:
Two things are wrong here, and they compound.
The teardown runs in a different context than the setup. For a response stream
_run_rpccloses the around context from_closing_stream, in whichever task drains the stream; for a streaming request it closes it from_spawn_background.contextvarsrefuses aresetof a token minted in another context, so the pair that is the normal way to scope anything for the duration of a call — a request id, a correlation id, an OpenTelemetry context — cannot be written against this seam for three of the four RPC kinds.AsyncClientTracingInterceptoralready knows this: its docstring says the span is made current only while the call is created because "detaching the OpenTelemetry context there fails". That warning lives on the one interceptor that hit it, whileAsyncAroundClientInterceptor's own docstring, the interceptor rows of the API table and rule 13 of the agents page all describearound_callas code that runs before and after the whole RPC, with nothing about the context boundary.A teardown failure changes the outcome of a call that already finished. The last block is the general case, with no contextvars in it: an exception from anything after the
yieldreaches the caller as that exception for unary-unary, unary-stream and stream-stream — a call the server answeredOKis delivered to application code asRuntimeError— and for stream-unary it is swallowed into the loop's exception handler. Interceptors are observability; a metrics push or a log write that fails must not take the response with it, and the same mistake must not have four different outcomes.What I think it needs, in this order:
yieldis logged at ERROR with the method on it and dropped, in all four paths — same as anafter_*failure would be treated in any observability chain. An exception raised before theyieldkeeps its current meaning: refusing the call.asyncio.Tasktakescontext=since 3.11, so the finalizer could close the around context in the verycontextvars.Contextthe setup ran in, which would make the token pair above work on every kind. If that turns out to be wrong for the streaming paths (the teardown deliberately runs in the consumer's task so a cancellation is seen), then say so instead: rule 13 and theAsyncAroundClientInterceptordocstring both need the sentence "the code after the yield may run in a different context than the code before it, so do not carry aContextVartoken or an OpenTelemetry context across the yield", plus the pattern that does work.Lab:
probe.pyandinterceptors_lab.pyin https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-07-grpc-interceptors-and-streams.