From 787127f0c66e15fc648602c99d1b9f7b3f57c561 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Wed, 24 Jun 2026 12:42:55 -0600 Subject: [PATCH] fix: preserve exception type when a transport error has a blank message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit httpx timeout and connection-reset exceptions stringify to "" (e.g. `str(httpx.ReadTimeout(""))` is ""). The high-level helpers caught these with `errors = [{"message": str(e)}]`, so a timed-out request surfaced as `[{"message": ""}]` — a blank, undiagnosable error. This was most visible on `async_mutate`, where async httpx timeouts always carry an empty message. Add an `exception_errors` helper that falls back to `repr(error)` when the message is blank, keeping the exception class name (e.g. `ReadTimeout('')`), and use it in `query`, `mutate`, `async_query`, and `async_mutate`. A real message is still reported verbatim. Includes an integration test driving a real GraphQLClient against a local HTTP server stalled past `post_timeout`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Uhj6eA8d9jcAzzHuzqtPod --- CHANGELOG.md | 4 + pygqlc/GraphQLClient.py | 20 ++- pygqlc/__version__.py | 2 +- .../gql_client/test_timeout_empty_message.py | 125 ++++++++++++++++++ 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 tests/pygqlc/gql_client/test_timeout_empty_message.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6403fa6..0065b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/pygqlc/GraphQLClient.py b/pygqlc/GraphQLClient.py index bc2fb19..801af56 100644 --- a/pygqlc/GraphQLClient.py +++ b/pygqlc/GraphQLClient.py @@ -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. @@ -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 @@ -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: @@ -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: @@ -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: diff --git a/pygqlc/__version__.py b/pygqlc/__version__.py index 2ae7a96..429dcfe 100644 --- a/pygqlc/__version__.py +++ b/pygqlc/__version__.py @@ -1 +1 @@ -__version__ = "3.8.2" +__version__ = "3.8.3" diff --git a/tests/pygqlc/gql_client/test_timeout_empty_message.py b/tests/pygqlc/gql_client/test_timeout_empty_message.py new file mode 100644 index 0000000..8ce88c1 --- /dev/null +++ b/tests/pygqlc/gql_client/test_timeout_empty_message.py @@ -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)