Skip to content

around_call: a ContextVar token cannot cross the yield on three of four RPC kinds, and a failing teardown replaces a successful response #19

Description

@AlexeyShalaev

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:

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions