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.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`.

## [3.8.2] - 2026-06-23

- [Fixed] `_new_conn` now closes the previous WSS connection before opening a new one, instead of overwriting `self._conn` and abandoning the old socket. On reconnect (when `_sub_routing_loop` sets `wss_conn_halted` and calls `_new_conn`) the orphaned socket lingered half-open on the server — no TCP FIN was ever sent — so the gateway kept fanning subscription events into a dead connection, accumulating an unbounded send buffer/mailbox until it OOMed. A new private `_close_conn` helper best-effort closes `self._conn` and clears the handle; it runs at the top of `_new_conn` so the server receives a FIN and tears down the stale subscription, and is reused by `close()` to drop the duplicated teardown.
Expand Down
20 changes: 16 additions & 4 deletions pygqlc/GraphQLClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ def has_errors(result):
return bool(errors)


def exception_errors(error: Exception) -> list[dict]:
"""Build the `errors` payload for an exception raised while executing.

Some transport failures carry an empty message, so `str(error)` is "".
httpx timeouts and connection resets are the common ones —
`str(httpx.ReadTimeout(""))` is "". Reporting those as `[{"message": ""}]`
hides what actually failed, so fall back to `repr(error)` (which keeps the
exception class name) whenever the message is blank.
"""
return [{"message": str(error) or repr(error)}]


@lru_cache(maxsize=128)
def _data_flatten_cacheable(data_str, single_child):
"""Internal cacheable version of data_flatten using string representation of data.
Expand Down Expand Up @@ -346,7 +358,7 @@ def query(
if flatten and data is not None:
data = data_flatten(data, single_child=single_child)
except Exception as e:
errors = [{"message": str(e)}]
errors = exception_errors(e)
return data, errors

# * Query high level implementation
Expand Down Expand Up @@ -398,7 +410,7 @@ def mutate(
try:
response = self.execute(mutation, variables)
except Exception as e:
errors = [{"message": str(e)}]
errors = exception_errors(e)
finally:
response_errors = response.get("errors", [])
if response_errors:
Expand Down Expand Up @@ -1249,7 +1261,7 @@ async def async_query(
if flatten and data is not None:
data = data_flatten(data, single_child=single_child)
except Exception as e:
errors = [{"message": str(e)}]
errors = exception_errors(e)
return data, errors

async def async_query_one(self, query: str, variables: dict | None = None) -> tuple:
Expand Down Expand Up @@ -1285,7 +1297,7 @@ async def async_mutate(
try:
response = await self.async_execute(mutation, variables)
except Exception as e:
errors = [{"message": str(e)}]
errors = exception_errors(e)
finally:
response_errors = response.get("errors", [])
if response_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.2"
__version__ = "3.8.3"
125 changes: 125 additions & 0 deletions tests/pygqlc/gql_client/test_timeout_empty_message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Transport exceptions must not collapse into a blank `[{"message": ""}]`.

httpx timeout and connection-reset exceptions carry an empty message, so
`str(error)` is "". When `execute`/`async_execute` raise one, the high-level
`query`/`mutate` helpers must still report a non-empty, diagnosable error that
preserves the exception type — not a meaningless `[{"message": ""}]` that hides
what actually failed.

These tests use a REAL local HTTP server (no mocks) that stalls past the
client's `post_timeout`, a real `GraphQLClient`, and a real mutation, so the
timeout and its empty message are produced by httpx itself.
"""

import http.server
import threading

import httpx
import pytest

from pygqlc import GraphQLClient
from pygqlc.GraphQLClient import exception_errors

MUTATION = """mutation($data:[CreateBulkThingParams]!){
createBulkThings(things:$data){
successful
messages { message code field }
result{things{id}}
}
}
"""

VARIABLES = {"data": [{"name": "a"}, {"name": "b"}]}


def _mentions_timeout(message):
lowered = message.lower()
# async httpx -> repr "ReadTimeout('')"; sync httpx -> "timed out".
return "timeout" in lowered or "timed out" in lowered


class _StallingHandler(http.server.BaseHTTPRequestHandler):
"""Accepts the POST, then sleeps long enough to trip the client read timeout."""

def do_POST(self): # noqa: N802 (http.server API)
length = int(self.headers.get("Content-Length", 0))
self.rfile.read(length)
# Sleep well past the client's post_timeout so httpx raises ReadTimeout.
self._stall.wait(timeout=5)
try:
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"data": null}')
except (BrokenPipeError, ConnectionResetError):
# Client already timed out and closed the socket — expected.
pass

def log_message(self, *_args): # silence the server access log
pass


@pytest.fixture
def stalling_server():
stall = threading.Event()
_StallingHandler._stall = stall
server = http.server.HTTPServer(("127.0.0.1", 0), _StallingHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
try:
yield f"http://{host}:{port}/api"
finally:
stall.set() # release any in-flight handler
server.shutdown()
thread.join(timeout=5)


@pytest.fixture
def gql_to(stalling_server):
gql = GraphQLClient()
gql.addEnvironment(
"stall",
url=stalling_server,
wss="ws://127.0.0.1:1/socket/websocket",
headers={"Authorization": "Bearer test"},
post_timeout=1, # 1s; the server stalls ~5s
default=True,
)
return gql


def test_exception_errors_preserves_type_when_message_is_blank():
# httpx timeouts stringify to "" — the payload must not be blank.
assert str(httpx.ReadTimeout("")) == ""
[error] = exception_errors(httpx.ReadTimeout(""))
assert error["message"].strip() != ""
assert "ReadTimeout" in error["message"]


def test_exception_errors_keeps_a_real_message_verbatim():
[error] = exception_errors(ValueError("boom"))
assert error == {"message": "boom"}


@pytest.mark.asyncio
async def test_async_mutate_timeout_surfaces_diagnosable_error(gql_to):
data, errors = await gql_to.async_mutate(MUTATION, variables=VARIABLES)

assert data is None
assert len(errors) == 1
message = errors[0].get("message") or ""
assert message.strip() != "", (
"A timed-out request must not be reported as a blank message; "
"the exception type must survive so the failure is diagnosable."
)
assert _mentions_timeout(message)


def test_mutate_timeout_surfaces_diagnosable_error(gql_to):
data, errors = gql_to.mutate(MUTATION, variables=VARIABLES)

assert data is None
assert len(errors) == 1
message = errors[0].get("message") or ""
assert message.strip() != ""
assert _mentions_timeout(message)