Propagate cancellation out of the reconnect loop - #981
Conversation
The reconnect loop's per-server except arm caught asyncio.CancelledError alongside TimeoutError and continued to the next server, swallowing the cancellation. A close() landing while the loop was inside establish_connection then kept trying servers instead of stopping, blocking close() on the read task until the whole reconnect schedule ran dry. Re-raise CancelledError; only TimeoutError is a per-server failure.
|
Claude finished @caspervonb's task in 3m 16s —— View job PR Review
The fix is correct and the approach is minimal. The source change is small (+4/-2) and the test comprehensively covers the failure mode. Source change ( Test ( One thing to be aware of (pre-existing, not introduced here): when |
| except (asyncio.CancelledError, TimeoutError) as e: | ||
| logger.error("Failed to connect to %s: %s", server, type(e).__name__) | ||
| except asyncio.CancelledError: | ||
| raise |
There was a problem hiding this comment.
The outer except Exception: at the while-loop level (~line 1062) was already safe — asyncio.CancelledError is a BaseException in Python 3.8+ (and nats-core targets 3.13+), so it wouldn't have been caught there. The only place it was explicitly swallowed was this inner per-server handler, which this change correctly fixes.
One side-effect worth noting: when CancelledError propagates out of this block, the cleanup at the bottom of the while loop (self._reconnecting = False, self._status = ClientStatus.CLOSED, lines 1065–1067) is never reached, leaving the client in a stuck RECONNECTING/_reconnecting=True state. close() recovers cleanly from this (it overrides both), and the test guards against it with await client.close() in the finally block — but bare cancellation without a subsequent close() call leaves the client unusable. This is a pre-existing structural concern, not something this PR introduces.
The reconnect loop's per-server
exceptarm caughtasyncio.CancelledErrortogether withTimeoutErrorandcontinued to the next server, so a cancellation landing insideestablish_connectionwas swallowed. Aclose()during an in-progress reconnect would then block on the read task until the entire reconnect schedule (servers × attempts × backoff) ran dry, instead of returning promptly.Re-raise
CancelledError; onlyTimeoutErroris a per-server failure. Covered by a test that drives a reconnect through the public API against an endpoint that goes silent on reconnect, cancels the in-flight loop, and asserts it stops.