Skip to content

fix: an around_call teardown cannot change a call's outcome, and shares its setup's context - #20

Merged
AlexeyShalaev merged 1 commit into
masterfrom
fix/around-call-context-and-teardown
Sep 7, 2026
Merged

fix: an around_call teardown cannot change a call's outcome, and shares its setup's context#20
AlexeyShalaev merged 1 commit into
masterfrom
fix/around-call-context-and-teardown

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Contributor

Summary

One around_call generator was being stepped from four different places, and both defects in #19 fall out of that.

kind its teardown ran in
unary-unary the same coroutine as the setup
unary-stream, stream-stream _closing_stream — whichever task drains the response
stream-unary _spawn_background(...), an ensure_future task whose context is a copy

contextvars compares contexts by identity, so every row but the first refused to reset a token the setup had minted — which rules out the pair that scopes a request id, a correlation id or an OpenTelemetry context to a single call, on three of the four kinds. And because the teardown lived in three callers, an exception out of it had three different fates: it replaced a response the server had already sent, it truncated a response stream, or it disappeared into the event loop's exception handler where nobody was looking.

_AroundScope now owns the generator and both problems are fixed where they come from:

  • A teardown cannot change an outcome. __aexit__ happens in exactly one place, which logs whatever it raises at ERROR against the method and drops it — the same treatment for all four kinds, whether the call succeeded or failed. CancelledError, KeyboardInterrupt and SystemExit still propagate: a task being torn down around the teardown is not the teardown failing, and swallowing it would strand a cancel the caller asked for. Raising before the yield still refuses the call, untouched.
  • The two sides of the yield share a context. asyncio.Task takes a context= and enters that object rather than copying it, so for the three kinds whose teardown finishes elsewhere the scope creates one contextvars.Context and steps the setup, the RPC creation and the teardown in it. The RPC creation is inside it deliberately: pinning only __aenter__/__aexit__ would make the token legal and make the value invisible to every layer below, which is the whole point of setting it.

Design decisions, and what I turned down

Unary-unary pins nothing. Its setup, call and teardown are already one coroutine and therefore already one context; the guarantee holds there for free. Pinning it anyway would be tidier in the source and would cost every unary-unary call an extra task per around layer — four of them in the canonical chain — on the busiest path in the kit. The four kinds end up observationally identical either way; only this one gets there without paying.

Documenting the constraint instead (the fallback the issue offers) was the answer only if the mechanism did not exist. It does, and a ContextVar set/reset pair is the canonical use of a seam like this one — "you cannot do the obvious thing here" is what you write when there is no alternative.

Swallowing teardown errors only where they reach the caller would have left stream-unary dropping them into the loop handler: the same mistake with four outcomes, which is what the report objects to.

Context.run without a task cannot drive a coroutine; anything built on it is a hand-rolled Task.

The tracing interceptor keeps intercept. Its docstring claimed around_call was unusable because "detaching the OpenTelemetry context there fails"; after this change that is simply untrue, and a stale rule on the one layer that hit the problem is how this stayed invisible for so long. The docstring now says what does keep it there — with opentelemetry-api installed and no SDK configured, the default state of the tracing extra, every span is non-recording and the layer does nothing at all, while an around scope would be opened for every call regardless, and on a streaming call that is a task per RPC. Moving a shipped observability layer onto a different seam is a change with its own risk and belongs in its own PR, not in the one that changed the seam underneath it.

Verification

probe.py from the issue's lab, against this branch (0.1.1 + this change, grpcio 1.83.1):

--- an around_call that sets a ContextVar before the yield and resets it after
    unary_unary    caller got the response
    unary_stream   caller got the response
    stream_unary   caller got the response
    stream_stream  caller got the response
--- the same interceptor on a stream the server fails mid-way
    unary_stream   caller got UNAVAILABLE: died at item 2
--- any teardown that raises, per RPC kind
    unary_unary    caller got the response       (+ ERROR: around_call teardown failed for /lab.Reports/Get)
    unary_stream   caller got the response       (+ ERROR: around_call teardown failed for /lab.Reports/Stream)
    stream_unary   caller got the response       (+ ERROR: around_call teardown failed for /lab.Reports/Upload)
    stream_stream  caller got the response       (+ ERROR: around_call teardown failed for /lab.Reports/Chat)

No loop-exception-handler lines left anywhere, and the mid-stream failure still reaches the caller as the status it is.

Both behaviours are tested per RPC kind twice over: at the seam in tests/unit/interceptors/test_base.py, driving the chain from a task of its own because that boundary is the whole bug, and against a live server in tests/integration/test_around_call.py. Reverting base.py to master's copy and rerunning them fails 8 of 9 unit cases and 7 of 8 integration cases — the one that passes either way is the contextvars case for unary-unary, which is exactly the row that already worked in the report's table.

make check clean; make test 525 passed, 2 xfailed, coverage 96.19% (was 508 passed, 96.02%).

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactoring / internal

Checklist

  • Tests added or updated
  • make check passes locally (ruff + mypy)
  • CHANGELOG.md updated under [Unreleased] — n/a, release-please writes it from the conventional commit
  • Documentation updated (if the public API changed), docs/agents.md included

docs/agents.md gets the seam's row in the interceptor table, the paragraph under it, and a rule of its own (14) for the two guarantees, which had nowhere to live before; rules 14–20 shift down by one. The interceptors guide gets the corrected "after the yield" bullet and a Scoping a value to one call section with the pattern that now works and what it costs. The README paragraph on the seam and the base module preamble say the same in fewer words.

Related issues

Closes #19

…es its setup's context

One `around_call` generator was stepped from four different places, and both defects
follow from that. `contextvars` compares contexts by identity, so the teardown of every
kind but unary-unary could not reset a token the setup had minted — the pair that scopes
a request id, a correlation id or an OpenTelemetry context to a single call. And an
exception raised after the `yield` had three different fates depending only on the kind
of call it wrapped: it replaced a response the server had already sent, it truncated a
response stream, or it disappeared into the event loop's exception handler.

`_AroundScope` now owns the generator. It closes it exactly once, logs at ERROR and drops
whatever the teardown raises — cancellation excepted, since a task being torn down is not
a teardown failing — and, for the three kinds whose teardown finishes in another task,
steps the setup, the RPC creation and the teardown in one `contextvars.Context` of its
own, which `asyncio.Task(context=...)` enters rather than copies. Unary-unary pins
nothing: its three phases are already one coroutine, and a task per call per layer is a
real cost on the busiest path in the kit.

The tracing interceptor's reason for avoiding the seam ("detaching the OpenTelemetry
context there fails") no longer holds; its docstring now says what does keep it on
`intercept`, which is the cost of an around scope on a layer that does nothing at all
when no SDK is configured.
@AlexeyShalaev
AlexeyShalaev merged commit 27b5c83 into master Sep 7, 2026
6 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the fix/around-call-context-and-teardown branch September 7, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant