Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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` now retries once on a fresh connection when a request fails with a transient transport error (`httpx.NetworkError` incl. `ReadError`/`ConnectError`, `RemoteProtocolError`, `PoolTimeout`, `ConnectTimeout`), not only on a closed event loop. httpx reuses pooled keep-alive connections; when the server has already closed one, the next request fails immediately — commonly `ReadError('')` with an empty message. Previously that surfaced to the caller (after 3.8.3, as `errors=[{'message': "ReadError('')"}]`); now the stale client is dropped and the request is retried once on a new connection. `ReadTimeout` is deliberately NOT retried (a genuinely slow request would just time out again). Added `_should_retry_on_fresh_connection` helper + `TRANSIENT_TRANSPORT_ERRORS`. The sync `execute` path already retried once on a fresh client, so only the async path changed.

## [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
54 changes: 39 additions & 15 deletions pygqlc/GraphQLClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@
websocket.WebSocketConnectionClosedException,
)

# HTTP transport failures where the connection in hand is dead/unusable — most
# commonly a stale keep-alive socket the server already closed, which surfaces as
# ReadError/RemoteProtocolError (often with an empty message). A single retry on a
# fresh connection typically succeeds. ReadTimeout is intentionally excluded: a
# genuinely slow request would just time out again, doubling the wait.
TRANSIENT_TRANSPORT_ERRORS = (
httpx.NetworkError, # ReadError, WriteError, ConnectError, CloseError
httpx.RemoteProtocolError, # server closed the connection without a full response
httpx.PoolTimeout,
httpx.ConnectTimeout,
)

# * Custom Exception class for GraphQL responses


Expand Down Expand Up @@ -1159,6 +1171,18 @@ 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:
"""Whether `error` warrants one retry on a brand-new connection.

True for a closed event loop and for transient transport failures
(`TRANSIENT_TRANSPORT_ERRORS`) — both mean the current connection is
unusable but a fresh one is likely to succeed.
"""
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 +1220,22 @@ 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
# Retry once on a fresh connection when the failure is a closed event
# loop or a transient transport error (e.g. a stale keep-alive socket
# the server already closed, surfacing as ReadError/RemoteProtocolError).
# Anything else (real timeouts, genuine network outages) re-raises.
if not self._should_retry_on_fresh_connection(e):
raise
# Best-effort close the stale client instead of leaking its transports,
# then retry once with a brand-new client/connection.
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"
83 changes: 83 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,83 @@
"""A stale/closed connection must be retried once on a fresh connection.

httpx reuses pooled keep-alive connections; when the server has already closed
one, the next request on it fails immediately with a transient transport error
(commonly `ReadError`/`RemoteProtocolError`, often with an empty message). A
single retry on a brand-new connection succeeds. `async_execute` must do that
retry instead of surfacing the error to the caller.
"""

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}}}
client = AsyncMock()
# First post hits a stale socket (ReadError); the retry on a fresh client succeeds.
client.post = AsyncMock(side_effect=[httpx.ReadError(""), _fake_response(payload)])

monkeypatch.setattr(gql_env, "_get_async_client", AsyncMock(return_value=client))
dropped = AsyncMock()
monkeypatch.setattr(gql_env, "_drop_async_client", dropped)

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

assert result == payload
assert client.post.call_count == 2 # original + one retry
dropped.assert_awaited_once() # stale client was 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