feat: retire idle keep-alive connections after 2s to avoid stale-socket ReadError (OPS-4976)#78
feat: retire idle keep-alive connections after 2s to avoid stale-socket ReadError (OPS-4976)#78doctorcorral wants to merge 3 commits into
Conversation
…XPIRY)
Pass httpx.Limits(keepalive_expiry=2.0) (down from httpx's 5.0 default, tunable
via PYGQLC_KEEPALIVE_EXPIRY) to both the sync and async clients so a socket the
server/LB has already closed during an idle gap is never reused -- the stale
keep-alive that surfaces as ReadError(''). Standard pool caps
(max_connections=100, max_keepalive_connections=20) are kept explicitly, since
passing only keepalive_expiry would reset them to unlimited.
Pairs with the 3.8.4 fresh-connection retry: fewer stale sockets means the retry
-- which is unsafe to apply blindly to non-idempotent mutations and can
duplicate writes (e.g. workflowServiceRun, OPS-4976) -- fires far less often.
Bumps version to 3.8.5.
There was a problem hiding this comment.
Pull request overview
This PR mitigates stale keep-alive socket reuse (surfacing as httpx.ReadError('')) by shortening the HTTP connection pool’s idle keep-alive expiry to 2 seconds (configurable via PYGQLC_KEEPALIVE_EXPIRY) across both sync and async httpx clients, and ships the corresponding release bump and changelog entry.
Changes:
- Configure
httpx.Limits(keepalive_expiry=...)with preserved pool caps (100/20) for both sync and async clients. - Add tests asserting the default, env override behavior, and that pool caps remain intact.
- Bump version to
3.8.5and document the behavior change inCHANGELOG.md.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
pygqlc/GraphQLClient.py |
Introduces DEFAULT_KEEPALIVE_EXPIRY, reads PYGQLC_KEEPALIVE_EXPIRY, and applies httpx.Limits to client params. |
tests/pygqlc/gql_client/test_keepalive_expiry.py |
Adds coverage for default/override keepalive expiry and confirms pool caps are preserved. |
pygqlc/__version__.py |
Bumps package version to 3.8.5. |
CHANGELOG.md |
Adds a 3.8.5 entry describing the keepalive expiry change and rationale. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Addresses review on #78: - httpx applies the Client-level `limits` only to its default transport; with a custom transport (ipv4_only) the limits kwarg is ignored, so keepalive_expiry was silently dropped there (fell back to httpx's 5s). Stash the limits on self and pass them into HTTPTransport/AsyncHTTPTransport so the mitigation applies on both paths. - PYGQLC_KEEPALIVE_EXPIRY: a non-numeric or negative value now falls back to the default with a warning rather than raising during client construction. - Tests: compare the default against httpx.Limits().keepalive_expiry instead of a hardcoded 5.0 (robust to httpx upgrades); add coverage for the ipv4_only transport limits and the env-validation fallback.
There was a problem hiding this comment.
Overall Assessment
This PR adds httpx.Limits(keepalive_expiry=2.0) (tunable via PYGQLC_KEEPALIVE_EXPIRY) to both the sync and async client parameter dicts, and threads the same limits object into custom HTTPTransport/AsyncHTTPTransport instances when ipv4_only=True. The goal is to drop idle keep-alive sockets before a server/LB can close them, reducing ReadError('') occurrences. Version bumped to 3.8.5, changelog updated, and a dedicated test file added. Implementation is sound; no blocking bugs found.
Findings
No actionable findings.
Notes
- The new
test_keepalive_expiry.pyfollows the repo's TDD/hermetic conventions: it resets the Singleton between tests, usesmonkeypatchfor env isolation, and avoids real sockets or sleeps. - The docstring on
_resolve_keepalive_expiryand the__init__comment block explicitly document why pool caps are preserved and why the fallback-with-warning behavior exists; both were considered but not flagged. - The ipv4_only transport test uses lightweight monkey-patching of
httpx.HTTPTransport/AsyncHTTPTransportto assert thatlimits=self._limitsis passed through, which is the minimal integration test the design requires.
|
Thanks @JoaquinTripp and Copilot — all addressed in 🔴 🟡 Env var parsed without validation (Copilot) — fixed. 🟡 Test hardcodes httpx's 5.0 default (Copilot) — fixed. Now asserts No version change — corrections within the same unreleased 3.8.5. |
…3.8.6 client-lifecycle fixes)
There was a problem hiding this comment.
Overall Assessment
This PR adds httpx.Limits(keepalive_expiry=2.0) (tunable via PYGQLC_KEEPALIVE_EXPIRY) to both sync and async HTTP clients, threading the same Limits object into custom ipv4_only transports. The implementation is sound: pool caps are preserved explicitly, invalid env values degrade gracefully with a warning, and the new test file covers the key behaviors. No blocking bugs found.
Findings
No actionable findings.
Notes
- The
fresh_clientfixture correctly pops theGraphQLClientsingleton before/after each test so__init__re-reads the env var — this is the right pattern for testing a singleton that captures env at construction time. - Considered flagging that
_limitsis captured once per singleton lifetime and won't reflect runtime env changes, but the docstring on_resolve_keepalive_expiryand the singleton design make this intentional; recorded here rather than as a finding. - Test coverage is comprehensive for the new behavior (default, override, fallback, ipv4_only transport threading) and follows the repo's "no real sockets, no sleeps" guideline.
| @@ -1 +1 @@ | |||
| __version__ = "3.8.6" | |||
| __version__ = "3.8.7" | |||
What
Sets
httpx.Limits(keepalive_expiry=2.0)(down from httpx's 5.0 default, tunable viaPYGQLC_KEEPALIVE_EXPIRY) on both the sync and async clients, so an idle pooled connection is dropped before a server/LB with a shorter idle timeout closes it. Keeps the standard pool caps (max_connections=100,max_keepalive_connections=20) explicitly — passing onlykeepalive_expirywould reset them to unlimited.Why
The recurring
ReadError('')is a stale keep-alive socket: the server closes an idle connection, the client reuses it, and reading the response fails. Retiring idle sockets after 2s (hot connections under load are reused within ms, so pooling is preserved) means we stop handing back already-closed sockets.This pairs with the 3.8.4 fresh-connection retry. That retry papered over the symptom but is unsafe to apply blindly to non-idempotent mutations: a
ReadErrorfires while reading the response, so the server may have already committed the write, and the re-POST duplicates it. That's the root of OPS-4976 (duplicateworkflowServiceRunrows wedging valiotworkflows state runs on femsa-prod). Fewer stale sockets ⇒ the retry fires far less often. The durable guard for the duplicate-write race is a unique constraint on the Jobs side (separate PR).Tests
tests/pygqlc/gql_client/test_keepalive_expiry.py: default 2s on both clients, env override viaPYGQLC_KEEPALIVE_EXPIRY, pool caps preserved (100/20), and default < httpx's 5s.gql_clienttransient-retry suite still green.Version → 3.8.5. Part of OPS-4976.