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

- [Changed] The HTTP clients now retire idle keep-alive connections after 2s (`httpx.Limits(keepalive_expiry=2.0)`, down from httpx's 5.0 default), tunable via the `PYGQLC_KEEPALIVE_EXPIRY` env var. This stops the client from reusing a socket the server/LB has already closed during an idle gap — the stale keep-alive that surfaces as `ReadError('')`. Hot connections under load are reused within milliseconds, so pooling is preserved; only genuinely idle sockets are dropped. Pairs with the 3.8.4 fresh-connection retry: fewer stale sockets to begin with, so the retry (which is unsafe to apply blindly to non-idempotent mutations) fires far less often.

## [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.
Expand Down
27 changes: 24 additions & 3 deletions pygqlc/GraphQLClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import asyncio
import os
import traceback
import time
import threading
Expand Down Expand Up @@ -50,6 +51,14 @@
httpx.ConnectTimeout,
)

# Drop an idle pooled keep-alive connection after this many seconds so we never
# reuse a socket the server has already closed (the stale keep-alive that
# surfaces as ReadError('')). httpx defaults to 5.0, which is long enough for a
# server/LB with a shorter idle timeout to close the socket first. 2.0 sits below
# any reasonable server idle timeout while still pooling hot connections under
# load (those are reused within milliseconds). Tunable via PYGQLC_KEEPALIVE_EXPIRY.
DEFAULT_KEEPALIVE_EXPIRY = 2.0

# * Custom Exception class for GraphQL responses


Expand Down Expand Up @@ -286,9 +295,21 @@ def __init__(self):
self.pingIntervalTime = 15
self.pingTimer = time.time()

# Setup common client parameters
self.client_params = {"http2": True}
self.async_client_params = {"http2": True}
# Setup common client parameters. Retire idle keep-alive connections
# quickly (see DEFAULT_KEEPALIVE_EXPIRY) so a socket the server has
# already closed is never reused.
keepalive_expiry = float(
os.environ.get("PYGQLC_KEEPALIVE_EXPIRY", DEFAULT_KEEPALIVE_EXPIRY)
)
Comment thread
doctorcorral marked this conversation as resolved.
Outdated
# Keep httpx's standard pool sizing (passing only keepalive_expiry would
# reset both maxes to None / unlimited); just shorten the idle expiry.
limits = httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=keepalive_expiry,
)
self.client_params = {"http2": True, "limits": limits}
self.async_client_params = {"http2": True, "limits": limits}
Comment thread
doctorcorral marked this conversation as resolved.
Outdated

# Reuse HTTP client for better performance
self._http_client = None
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.4"
__version__ = "3.8.5"
53 changes: 53 additions & 0 deletions tests/pygqlc/gql_client/test_keepalive_expiry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Idle keep-alive connections are retired quickly via httpx ``keepalive_expiry``
so a socket the server already closed is never reused — the stale keep-alive that
surfaces as ``ReadError('')``. Default 2s, tunable via ``PYGQLC_KEEPALIVE_EXPIRY``.
"""

import httpx
import pytest

from pygqlc import GraphQLClient
from pygqlc.GraphQLClient import DEFAULT_KEEPALIVE_EXPIRY
from pygqlc.helper_modules.Singleton import Singleton


@pytest.fixture
def fresh_client():
"""Build GraphQLClient instances whose ``__init__`` re-reads the env, without
leaking state into the process-wide singleton other tests share."""
saved = Singleton._instances.pop(GraphQLClient, None)

def _build():
Singleton._instances.pop(GraphQLClient, None)
return GraphQLClient()

yield _build

Singleton._instances.pop(GraphQLClient, None)
if saved is not None:
Singleton._instances[GraphQLClient] = saved


def test_default_keepalive_expiry_on_both_clients(fresh_client, monkeypatch):
monkeypatch.delenv("PYGQLC_KEEPALIVE_EXPIRY", raising=False)
gql = fresh_client()
for params in (gql.client_params, gql.async_client_params):
limits = params["limits"]
assert isinstance(limits, httpx.Limits)
assert limits.keepalive_expiry == DEFAULT_KEEPALIVE_EXPIRY
# httpx's standard pool caps must be preserved, not reset to None.
assert limits.max_connections == 100
assert limits.max_keepalive_connections == 20


def test_keepalive_expiry_env_override(fresh_client, monkeypatch):
monkeypatch.setenv("PYGQLC_KEEPALIVE_EXPIRY", "0.5")
gql = fresh_client()
assert gql.client_params["limits"].keepalive_expiry == 0.5
assert gql.async_client_params["limits"].keepalive_expiry == 0.5


def test_default_below_httpx_default():
# The point of the mitigation: shorter than httpx's 5.0s default so a
# server/LB with a shorter idle timeout can't close the socket first.
assert DEFAULT_KEEPALIVE_EXPIRY < 5.0
Comment thread
doctorcorral marked this conversation as resolved.
Outdated