Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## [3.8.7] - 2026-07-31

- [Fixed] `async_execute` now recovers when the shared `httpx.AsyncClient`'s internal asyncio primitive (the connection-pool `Event`/`Lock`) is bound to a different event loop than the one it's being called from. The shared `GraphQLClient` is a long-lived singleton, so its `_async_client` is first bound to whichever loop touches it (a Temporal worker's loop). When a caller later runs on a fresh loop — `valuechainos_queues`'s `trigger_by_subscription` creates one per subscription callback via `asyncio.run()` — `client.post(...)` raises `RuntimeError: <asyncio.locks.Event object …> is bound to a different event loop`, which surfaced as `get_workflow_config` failing every `QUEUE_REPLENISHMENT_FOR_CSV_REPORT` trigger (OPS-5447). `_should_retry_on_fresh_connection` now admits that message alongside "Event loop is closed" / "client has been closed", so `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. (OPS-5447)

## [3.8.6] - 2026-06-26

- [Fixed] `_get_async_client` reuses the shared client instead of recreating it every call. Its probe awaited a non-existent `get_timeout()`, so every call closed + rebuilt the client — under concurrency that closed it mid-use elsewhere (`Cannot send a request, as the client has been closed.`). Now rebuilt only when missing or `is_closed`, and that error is retryable.
Expand Down
15 changes: 10 additions & 5 deletions pygqlc/GraphQLClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -1150,9 +1150,14 @@ async def _drop_async_client(self):
@staticmethod
def _should_retry_on_fresh_connection(error: Exception) -> bool:
"""True when the connection is unusable but a fresh one should work:
a closed/closed-down client or a transient transport error."""
a closed/closed-down client, an asyncio primitive bound to a different
(dead) event loop, or a transient transport error."""
msg = str(error)
if "Event loop is closed" in msg or "client has been closed" in msg:
if (
"Event loop is closed" in msg
or "client has been closed" in msg
or "is bound to a different event loop" in msg
):
Comment on lines +1156 to +1160
return True
return isinstance(error, TRANSIENT_TRANSPORT_ERRORS)

Expand Down Expand Up @@ -1196,9 +1201,9 @@ async def async_execute(self, query: str, variables: dict | None = None) -> dict
if not self._should_retry_on_fresh_connection(e):
raise
# Retry on the SAME shared client (httpx opens a fresh connection).
# Only a closed event loop needs a full rebuild — and it's the only
# RuntimeError the predicate admits. Dropping the shared pool per
# transient error would churn connections.
# 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.
Comment on lines +1204 to +1206
if isinstance(e, RuntimeError):
await self._drop_async_client()
client = await self._get_async_client()
Expand Down
2 changes: 1 addition & 1 deletion pygqlc/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "3.8.6"
__version__ = "3.8.7"
43 changes: 43 additions & 0 deletions tests/pygqlc/gql_client/test_transient_transport_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ def gql_env():
(httpx.ConnectError(""), True),
(RuntimeError("Event loop is closed"), True),
(RuntimeError("Cannot send a request, as the client has been closed."), True),
# 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,
),
Comment on lines +46 to +55
(httpx.ReadTimeout(""), False), # genuine slow request — don't auto-retry
(ValueError("nope"), False),
],
Expand Down Expand Up @@ -95,6 +105,39 @@ async def test_async_execute_rebuilds_client_on_closed_event_loop(gql_env, monke
dropped.assert_awaited_once() # whole client rebuilt for a dead event loop


@pytest.mark.asyncio
async def test_async_execute_rebuilds_client_on_bound_to_different_event_loop(
gql_env, monkeypatch
):
payload = {"data": {"createBulkThings": {"successful": True}}}
# The shared client's internal asyncio primitive is bound to a prior loop
# (the long-lived GraphQLClient was first used on another event loop). Like
# a closed event loop, this invalidates the whole client, so it is dropped
# and rebuilt on the current loop before retrying.
stale = AsyncMock()
stale.post = AsyncMock(
side_effect=RuntimeError(
"<asyncio.locks.Event object at 0x7f218c298650 [unset]> "
"is bound to a different event loop"
)
)
Comment on lines +119 to +123
fresh = AsyncMock()
fresh.post = AsyncMock(return_value=_fake_response(payload))

monkeypatch.setattr(
gql_env, "_get_async_client", AsyncMock(side_effect=[stale, fresh])
)
dropped = AsyncMock()
monkeypatch.setattr(gql_env, "_drop_async_client", dropped)

result = await gql_env.async_execute("query { things { id } }")

assert result == payload
stale.post.assert_awaited_once()
fresh.post.assert_awaited_once()
dropped.assert_awaited_once() # whole client rebuilt for a loop-bound primitive


@pytest.mark.asyncio
async def test_async_execute_does_not_retry_read_timeout(gql_env, monkeypatch):
client = AsyncMock()
Expand Down
Loading
Loading