fix: rebuild async client when its loop-bound primitive is on a different loop (3.8.7) - #81
Conversation
…rent loop (3.8.7) async_execute's _should_retry_on_fresh_connection now admits the RuntimeError '<asyncio.locks.Event object ...> is bound to a different event loop' alongside 'Event loop is closed' / 'client has been closed'. The shared GraphQLClient is a long-lived singleton, so its httpx.AsyncClient (and the connection-pool Event/Lock inside it) is first bound to whichever loop touches it (a Temporal worker's loop). When a caller later runs on a fresh loop — valuechainos_queues' trigger_by_subscription creates one per subscription callback via asyncio.run() — client.post raises that RuntimeError, surfacing as get_workflow_config failing every QUEUE_REPLENISHMENT_FOR_CSV_REPORT trigger (OPS-5447). async_execute drops the stale client and rebuilds it on the current loop before retrying — the same recovery path already used for a closed loop. uv.lock refreshed via uv lock --upgrade.
There was a problem hiding this comment.
Pull request overview
Note
Copilot could not run the full agentic suite for this review because it was automatically requested on a bot-authored pull request. Request a review from Copilot under Reviewers to retry with the full agentic suite. Improved support for bot-authored pull requests is coming soon.
Adds recovery for async_execute when the shared httpx.AsyncClient is tied to a different asyncio event loop, by treating that RuntimeError as retryable and rebuilding the client before retrying.
Changes:
- Extend
_should_retry_on_fresh_connectionto treat “bound to a different event loop” as retryable. - Add an async test to ensure
async_executedrops/rebuilds the async client on that failure mode. - Bump version and document the fix in the changelog.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| tests/pygqlc/gql_client/test_transient_transport_retry.py | Adds regression coverage for rebuilding the async client when an asyncio primitive is bound to a different loop. |
| pygqlc/version.py | Bumps package version to 3.8.7 for the fix release. |
| pygqlc/GraphQLClient.py | Updates retry predicate to detect the “different event loop” RuntimeError and clarifies retry/rebuild behavior. |
| CHANGELOG.md | Documents the new recovery behavior and its real-world trigger. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Only a dead/wrong event loop needs a full rebuild — and those are | ||
| # the only RuntimeErrors the predicate admits. Dropping the shared | ||
| # pool per transient transport error would churn connections. |
| if ( | ||
| "Event loop is closed" in msg | ||
| or "client has been closed" in msg | ||
| or "is bound to a different event loop" in msg | ||
| ): |
| # An asyncio primitive (httpx/httpcore's connection-pool Event/Lock) is | ||
| # bound to the loop the shared client was first used on; calling from a | ||
| # fresh loop (asyncio.run per callback, a Temporal loop vs. a listener | ||
| # loop) raises this — a fresh client on the current loop is the fix. | ||
| ( | ||
| RuntimeError( | ||
| "<asyncio.locks.Event object at 0x7f218c298650 [unset]> is bound to a different event loop" | ||
| ), | ||
| True, | ||
| ), |
| side_effect=RuntimeError( | ||
| "<asyncio.locks.Event object at 0x7f218c298650 [unset]> " | ||
| "is bound to a different event loop" | ||
| ) | ||
| ) |
✅ Closing as redundantThe base branch advanced and this fix is no longer needed — A competing fix (PR #82, commit 45fb003) already merged to main as 3.8.7 with the same root-cause resolution: it adds the identical 'is bound to a different event loop' clause to _should_retry_on_fresh_connection (origin/main:GraphQLClient.py:1176) AND a superior per-loop httpx.AsyncClient cache (weak-keyed by running loop) that prevents cross-loop binding outright. main is already at version 3.8.7 with a CHANGELOG entry citing the same QUEUE_REPLENISHMENT_FOR_CSV_REPORT error. My PR's core change is a strict subset of what landed; the remaining diff is only duplicate version/CHANGELOG entries. The bug no longer reproduces on the new base. Closing this PR automatically. If that's wrong, reopen it and leave a 🤖 Implementer · GLM 5.2 (Fireworks) |
Description
async_executenow recovers when the sharedhttpx.AsyncClient's internal asyncio primitive (the httpcore connection-poolEvent/Lock) is bound to a different event loop than the one it's being called from.Root cause
The reported error (OPS-5447) was:
I traced this through the installed stack rather than trusting the issue's guess at
valiotworkflows:valiotworkflows.gql_activitiescapturesself._loop/self._executorin__init__but never uses them — not the source. There is no module/class-levelasyncio.Eventanywhere invaliotworkflowsorpygqlc.get_workflow_config→self._gql.async_query→GraphQLClient.async_execute→self._async_client.post(...). Theasyncio.Eventis httpx/httpcore-internal, living on the long-livedGraphQLClient._async_client(a singleton).valuechainos_queues/services/workflows/trigger_by_subscription.py:51creates a fresh loop per subscription callback viaasyncio.run(), soclient.postruns on a different loop →asyncio.mixins._LoopBoundMixin._get_loop()raisesRuntimeError(f'{self!r} is bound to a different event loop').RuntimeErrors by dropping the stale client and rebuilding it on the current loop (async_execute's retry path). It simply did not recognize this message, so the error propagated intoconfig_errorsand was re-raised byget_workflow_config.This is genuinely a cross-repo root cause: the issue is filed against
valiot/ValueChainOS-Queues, but the fix must live inpygqlc— no change inValueChainOS-Queuesorvaliotworkflowscan fix it (the sharedGraphQLClientis used from multiple loops). A persistent-loop change intrigger_by_subscriptionalone would not fix it either, because the sameGraphQLClientis shared with the Temporal worker's loop.Fix
_should_retry_on_fresh_connectionnow admits"is bound to a different event loop"alongside"Event loop is closed"/"client has been closed". The existingisinstance(e, RuntimeError)branch inasync_executethen drops the stale client (_drop_async_client) and rebuilds it on the current loop (_get_async_client) before retrying — the same recovery path already used for a closed loop. One-line behavioral change; no new branches, no API change.Consumers (
valuechainos-queuesdepends onpygqlc>=3.8.6) pick this up on next install once 3.8.7 is published. A follow-up bump of that floor to>=3.8.7is optional belt-and-suspenders.Fixes OPS-5447
Type of change
How Has This Been Tested?
TDD — failing test first, then the one-line predicate change to green.
test_should_retry_on_fresh_connection: the exact productionRuntimeError("<asyncio.locks.Event object …> is bound to a different event loop")must returnTrue(wasFalse→ red).test_async_execute_rebuilds_client_on_bound_to_different_event_loop: mirrors the existing closed-loop rebuild test —async_executedrops the stale client, rebuilds on the current loop, and the retry succeeds (was re-raising → red).uv run pytest tests/pygqlc/gql_client/test_transient_transport_retry.py tests/pygqlc/gql_client/test_async_client_lifecycle.py tests/pygqlc/gql_client/test_timeout_empty_message.py→ 25 passed.uvx ruff format --check pygqlc/ tests/→ 34 files already formatted (changed files clean).uvx bandit -r . -s B101 -ll --exclude $(find . -type d -name '.venv' | paste -sd, -)→ No issues identified.uv lock --upgrade+uv sync --frozen→ lockfile committed (required bypython-check-outdated.yaml).Test Configuration:
mise.toml)Notes on the wider suite
uv run pytest(full) reports 25 passed + 25 errors. The 25 errors are allSystemExit: Check your environment variablesfrom the session-scopedgqlfixture intests/conftest.py, which requiresAPI/WSS/TOKENsecrets (CI injects them viaci.yml; they are unavailable in this sandbox). They are pre-existing and env-gated, and none touch the async-client retry path this PR changes.README.mdhas a pre-existingruff formatdrift (unrelated, not a CI gate —ci.ymlruns only pytest); left untouched to keep this diff scoped to the bug.Checklist:
💬 Continue the agent chat (live transcript, tools, follow-ups):
https://palantir.valiot.io/agent-tasks?task=04a5407f-fa44-44ff-b485-599aac487eb3