Skip to content
Merged
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.4] - 2026-06-25

- [Fixed] `async_execute` retries once on a fresh connection for transient transport errors (`httpx.NetworkError`/`ReadError`, `RemoteProtocolError`, `PoolTimeout`, `ConnectTimeout`) — typically a stale keep-alive socket the server closed, surfacing as `ReadError('')`. `ReadTimeout` is excluded (a slow request would just time out again). Sync `execute` already behaved this way.

## [3.8.3] - 2026-06-24

- [Fixed] `query`, `mutate`, `async_query`, and `async_mutate` no longer collapse a raised transport exception into `[{"message": ""}]`. httpx timeout and connection-reset exceptions carry an empty message (`str(httpx.ReadTimeout(""))` is `""`), so the previous `errors = [{"message": str(e)}]` surfaced a blank, undiagnosable error — most visibly on `async_mutate`, where async httpx timeouts always stringify to "". A new `exception_errors` helper falls back to `repr(error)` when the message is blank, preserving the exception class name (e.g. `ReadTimeout('')`) so the caller can tell what actually failed. Added an integration test that drives a real `GraphQLClient` against a local HTTP server stalled past `post_timeout`.
Expand Down
45 changes: 30 additions & 15 deletions pygqlc/GraphQLClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@
websocket.WebSocketConnectionClosedException,
)

# Transport failures where the connection is dead but a fresh one will likely
# work — commonly a stale keep-alive socket (surfaces as ReadError('')). Retried
# once on a new connection. ReadTimeout is excluded: a slow request would just
# time out again.
TRANSIENT_TRANSPORT_ERRORS = (
httpx.NetworkError, # ReadError, WriteError, ConnectError, CloseError
httpx.RemoteProtocolError,
httpx.PoolTimeout,
httpx.ConnectTimeout,
)

# * Custom Exception class for GraphQL responses


Expand Down Expand Up @@ -1159,6 +1170,14 @@ async def _drop_async_client(self):
# Loop gone / client partially torn down — nothing more we can do
log(LogLevel.WARNING, f"Warning: Error closing async client: {str(e)}")

@staticmethod
def _should_retry_on_fresh_connection(error: Exception) -> bool:
"""True when the connection is unusable but a fresh one should work:
a closed event loop or a transient transport error."""
if "Event loop is closed" in str(error):
return True
return isinstance(error, TRANSIENT_TRANSPORT_ERRORS)

async def async_execute(self, query: str, variables: dict | None = None) -> dict:
"""Async version of execute method that executes instructions of a query or mutation.

Expand Down Expand Up @@ -1196,22 +1215,18 @@ async def async_execute(self, query: str, variables: dict | None = None) -> dict
timeout=float(env.get("post_timeout", 60)),
)
except (httpx.RequestError, RuntimeError) as e:
# Check if this is an event loop issue or a network issue
if "Event loop is closed" in str(e):
# Event loop was closed - need to get a new client with a valid loop
# Best-effort close the stale client instead of leaking its transports
await self._drop_async_client()
# Try again with a new client
client = await self._get_async_client()
response = await client.post(
env["url"],
json=data,
headers=headers,
timeout=float(env.get("post_timeout", 60)),
)
else:
# Some other request error, re-raise
# Stale keep-alive socket or closed loop: drop the client and retry
# once on a fresh connection. ReadTimeout and others re-raise.
if not self._should_retry_on_fresh_connection(e):
raise
await self._drop_async_client()
client = await self._get_async_client()
response = await client.post(
env["url"],
json=data,
headers=headers,
timeout=float(env.get("post_timeout", 60)),
)

if response.status_code == 200:
return orjson.loads(response.content)
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.3"
__version__ = "3.8.4"
86 changes: 86 additions & 0 deletions tests/pygqlc/gql_client/test_transient_transport_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""A stale/closed pooled connection must be retried once on a fresh one.

When the server has closed a pooled keep-alive connection, the next request
fails with a transient transport error (e.g. `ReadError('')`). `async_execute`
should retry once on a new connection instead of surfacing it.
"""

from unittest.mock import AsyncMock

import httpx
import orjson
import pytest

from pygqlc import GraphQLClient


def _fake_response(payload):
resp = httpx.Response(status_code=200, content=orjson.dumps(payload))
return resp


@pytest.fixture
def gql_env():
gql = GraphQLClient()
gql.addEnvironment(
"retry-test",
url="http://127.0.0.1:1/api",
wss="ws://127.0.0.1:1/socket/websocket",
headers={"Authorization": "Bearer test"},
post_timeout=5,
default=True,
)
return gql


@pytest.mark.parametrize(
"error,expected",
[
(httpx.ReadError(""), True),
(httpx.RemoteProtocolError(""), True),
(httpx.ConnectTimeout(""), True),
(httpx.PoolTimeout(""), True),
(httpx.ConnectError(""), True),
(RuntimeError("Event loop is closed"), True),
(httpx.ReadTimeout(""), False), # genuine slow request — don't auto-retry
(ValueError("nope"), False),
],
)
def test_should_retry_on_fresh_connection(error, expected):
assert GraphQLClient._should_retry_on_fresh_connection(error) is expected


@pytest.mark.asyncio
async def test_async_execute_retries_once_on_stale_connection(gql_env, monkeypatch):
payload = {"data": {"createBulkThings": {"successful": True}}}
# Two distinct clients: the stale one fails, a brand-new one is used on retry.
stale = AsyncMock()
stale.post = AsyncMock(side_effect=httpx.ReadError(""))
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() # failed on the stale connection
fresh.post.assert_awaited_once() # retried on a brand-new connection
dropped.assert_awaited_once() # stale client dropped before retrying


@pytest.mark.asyncio
async def test_async_execute_does_not_retry_read_timeout(gql_env, monkeypatch):
client = AsyncMock()
client.post = AsyncMock(side_effect=httpx.ReadTimeout(""))
monkeypatch.setattr(gql_env, "_get_async_client", AsyncMock(return_value=client))
monkeypatch.setattr(gql_env, "_drop_async_client", AsyncMock())

with pytest.raises(httpx.ReadTimeout):
await gql_env.async_execute("query { things { id } }")

assert client.post.call_count == 1 # not retried