Skip to content
Open
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.8] - 2026-08-06

- [Fixed] The retry-on-fresh-connection predicate now also admits CPython's second cross-loop wording, `RuntimeError: Task ... got Future ... attached to a different loop` (`asyncio/tasks.py`), alongside 3.8.7's `is bound to a different event loop` (`asyncio/mixins.py`). 3.8.7's per-loop client cache is what actually prevents cross-loop reuse and remains the fix; this only completes the defensive belt, so any stale cross-loop state that still reaches a request self-heals via drop-and-rebuild instead of escaping to the caller — where `async_query`/`async_mutate` fold it into a GraphQL error list and it reads as an unexplained blank failure. Observed against 3.8.6 in a Temporal worker running each activity through its own `asyncio.run` (a fresh loop per run against a process-shared client).

## [3.8.7] - 2026-07-30

- [Fixed] The cached async client is now PER EVENT LOOP. 3.8.6's shared client bound its httpx connection-pool primitives to whichever loop first used it; any consumer running coroutines on more than one loop — e.g. a Temporal worker's main loop plus a subscription callback thread using `asyncio.run` per event (valuechainos-queues' `trigger_by_subscription`) — then failed with `RuntimeError: <asyncio.locks.Event ...> is bound to a different event loop` (observed live as `Error processing workflow QUEUE_REPLENISHMENT_FOR_CSV_REPORT`). Each loop now gets (and reuses) its own client; per-loop reuse keeps 3.8.6's no-churn goal, `_close()` schedules `aclose()` on each client's own loop, and "is bound to a different event loop" joined the retryable-on-fresh-connection predicate as a defensive self-heal.
Expand Down
3 changes: 3 additions & 0 deletions pygqlc/GraphQLClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -1173,7 +1173,10 @@ def _should_retry_on_fresh_connection(error: Exception) -> bool:
or "client has been closed" in msg
# Defensive: cannot arise with per-loop clients, but if stale cross-loop
# state ever surfaces, drop-and-rebuild self-heals instead of failing.
# Both wordings CPython emits: asyncio/mixins.py for a pool primitive,
# asyncio/tasks.py when a Task awaits another loop's Future.
or "is bound to a different event loop" in msg
or "attached to a different loop" in msg
):
return True
return isinstance(error, TRANSIENT_TRANSPORT_ERRORS)
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.7"
__version__ = "3.8.8"
32 changes: 29 additions & 3 deletions tests/pygqlc/gql_client/test_async_client_per_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ def test_each_loop_gets_its_own_client(client):
async def grab():
seen.append(await client._get_async_client())

with patch("pygqlc.GraphQLClient.httpx.AsyncClient", side_effect=lambda **_: AsyncMock(is_closed=False)):
with patch(
"pygqlc.GraphQLClient.httpx.AsyncClient",
side_effect=lambda **_: AsyncMock(is_closed=False),
):
asyncio.run(grab())
asyncio.run(grab())

Expand All @@ -57,7 +60,8 @@ async def grab_twice():
seen.append(await client._get_async_client())

with patch(
"pygqlc.GraphQLClient.httpx.AsyncClient", side_effect=lambda **_: AsyncMock(is_closed=False)
"pygqlc.GraphQLClient.httpx.AsyncClient",
side_effect=lambda **_: AsyncMock(is_closed=False),
) as ctor:
asyncio.run(grab_twice())

Expand All @@ -77,10 +81,32 @@ def make_client(**_):
created.append(mock)
return mock

with patch("pygqlc.GraphQLClient.httpx.AsyncClient", side_effect=make_client) as ctor:
with patch(
"pygqlc.GraphQLClient.httpx.AsyncClient", side_effect=make_client
) as ctor:
asyncio.run(client.async_execute("query { ok }"))
asyncio.run(client.async_execute("query { ok }"))

assert ctor.call_count == 2, "second loop must build its own client"
created[0].post.assert_awaited_once()
created[1].post.assert_awaited_once()


def test_stale_loops_client_is_released_without_awaiting_on_its_dead_loop(client):
"""The first loop's client is dropped, not awaited: its loop is closed by the
time the second run starts, so any aclose() there would itself raise."""
response = MagicMock(status_code=200, content=b'{"data": {"ok": true}}')
created = []

def make_client(**_):
mock = AsyncMock(is_closed=False)
mock.post.return_value = response
created.append(mock)
Comment on lines +98 to +104
return mock

with patch("pygqlc.GraphQLClient.httpx.AsyncClient", side_effect=make_client):
asyncio.run(client.async_execute("query { ok }"))
asyncio.run(client.async_execute("query { ok }"))

created[0].aclose.assert_not_awaited()
assert created[0].post.await_count == 1, "dead loop's client must not be reused"
Comment on lines +111 to +112
42 changes: 42 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,21 @@ def gql_env():
(httpx.ConnectError(""), True),
(RuntimeError("Event loop is closed"), True),
(RuntimeError("Cannot send a request, as the client has been closed."), True),
# Both cross-loop wordings CPython emits: asyncio/mixins.py phrases it
# "is bound to a different event loop", asyncio/tasks.py "attached to a
# different loop". Per-loop clients should prevent both; retrying is the belt.
(
RuntimeError(
"<asyncio.locks.Event object at 0x1> is bound to a different event loop"
),
True,
),
(
RuntimeError(
"Task <Task pending> got Future <Future pending> attached to a different loop"
),
True,
),
(httpx.ReadTimeout(""), False), # genuine slow request — don't auto-retry
(ValueError("nope"), False),
],
Expand Down Expand Up @@ -95,6 +110,33 @@ 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_cross_loop_future(gql_env, monkeypatch):
payload = {"data": {"createBulkThings": {"successful": True}}}
# Per-loop clients keep this from happening; if stale cross-loop state ever
# reaches a post anyway, the client is rebuilt rather than surfacing to the caller.
stale = AsyncMock()
stale.post = AsyncMock(
side_effect=RuntimeError(
"Task <Task pending> got Future <Future pending> attached to a different loop"
)
)
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
fresh.post.assert_awaited_once()
dropped.assert_awaited_once()


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