From c83560c67cd747c8499dff9f449562974cd45278 Mon Sep 17 00:00:00 2001 From: cayossarian Date: Wed, 31 Dec 2025 17:57:24 -0800 Subject: [PATCH] Recognize panel Keep-Alive at 5 sec, Handle httpx.RemoteProtocolError defensively --- .github/workflows/ci.yml | 6 +++--- CHANGELOG.md | 6 ++++++ poetry.lock | 4 ++-- pyproject.toml | 4 ++-- src/span_panel_api/client.py | 31 +++++++++++++++++++++++++++---- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cdbcca..4d7417d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Install Poetry uses: snok/install-poetry@v1 with: - version: latest + version: 2.1.4 virtualenvs-create: true virtualenvs-in-project: true @@ -67,7 +67,7 @@ jobs: - name: Install Poetry uses: snok/install-poetry@v1 with: - version: latest + version: 2.1.4 virtualenvs-create: true virtualenvs-in-project: true @@ -101,7 +101,7 @@ jobs: - name: Install Poetry uses: snok/install-poetry@v1 with: - version: latest + version: 2.1.4 virtualenvs-create: true virtualenvs-in-project: true diff --git a/CHANGELOG.md b/CHANGELOG.md index bfab83c..79bd27f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.14] - 12/2025 + +### Fixed in v1.1.14 + +- Recognize panel Keep-Alive at 5 sec, Handle httpx.RemoteProtocolError defensively + ## [1.1.9] - 9/2025 ### Fixed in v1.1.9 diff --git a/poetry.lock b/poetry.lock index 3013e85..8a94c36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -2434,4 +2434,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "8501c5db2ed2b29c5507bae23693faf00ec052bdd75caa5dc4b9c0c26ea4ed7c" +content-hash = "3b3d647ec72766638a8bd46f78b0d5743e5a891c8b635bd4898f50f5a2d8d337" diff --git a/pyproject.toml b/pyproject.toml index e014b75..23781ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "1.1.13" +version = "1.1.14" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} @@ -8,7 +8,7 @@ authors = [ readme = "README.md" requires-python = ">=3.10,<4.0" dependencies = [ - "httpx>=0.20.0,<0.29.0", + "httpx>=0.28.1,<0.29.0", "attrs>=22.2.0", "python-dateutil>=2.8.0", "click>=8.0.0", diff --git a/src/span_panel_api/client.py b/src/span_panel_api/client.py index 392f415..1884c7a 100644 --- a/src/span_panel_api/client.py +++ b/src/span_panel_api/client.py @@ -235,8 +235,10 @@ async def __aenter__(self) -> SpanPanelClient: self._client = self._get_unauthenticated_client() # Enter the httpx client context + # Must manually call __aenter__ - can't use async with because we need the client + # to stay open until __aexit__ is called (split context management pattern) try: - await self._client.__aenter__() + await self._client.__aenter__() # pylint: disable=unnecessary-dunder-call except Exception as e: # Reset state on failure self._client = None @@ -448,7 +450,7 @@ def _get_client(self) -> AuthenticatedClient | Client: "limits": httpx.Limits( max_keepalive_connections=5, # Keep connections alive max_connections=10, # Allow multiple connections - keepalive_expiry=30.0, # Keep connections alive for 30 seconds + keepalive_expiry=4.0, # Close before server's 5s keep-alive timeout ), } @@ -480,7 +482,7 @@ def _get_unauthenticated_client(self) -> Client: "limits": httpx.Limits( max_keepalive_connections=5, # Keep connections alive max_connections=10, # Allow multiple connections - keepalive_expiry=30.0, # Keep connections alive for 30 seconds + keepalive_expiry=4.0, # Close before server's 5s keep-alive timeout ), } @@ -506,7 +508,7 @@ def _get_authenticated_client(self) -> AuthenticatedClient: "limits": httpx.Limits( max_keepalive_connections=5, # Keep connections alive max_connections=10, # Allow multiple connections - keepalive_expiry=30.0, # Keep connections alive for 30 seconds + keepalive_expiry=4.0, # Close before server's 5s keep-alive timeout ), } @@ -696,6 +698,27 @@ async def _retry_with_backoff(self, operation: Callable[..., Awaitable[T]], *arg continue # Last attempt - re-raise raise + except httpx.RemoteProtocolError: + # Server closed connection (stale keep-alive) - all pooled connections likely dead + # Destroy client to force fresh connection pool on retry + if self._client is not None: + with suppress(Exception): + await self._client.__aexit__(None, None, None) + self._client = None + + # If in context mode, recreate client to maintain invariant that _client is not None + if self._in_context: + if self._access_token: + self._client = self._get_authenticated_client() + else: + self._client = self._get_unauthenticated_client() + # Must manually enter context - can't use async with here as we're already in a context + # and need to keep client alive for retry. This matches the pattern in __aenter__ (line 239). + await self._client.__aenter__() # pylint: disable=unnecessary-dunder-call + + if attempt < max_attempts - 1: + continue # Immediate retry - no delay needed + raise # This should never be reached, but required for mypy type checking raise SpanPanelAPIError("Retry operation completed without success or exception")