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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
[project]
name = "span-panel-api"
version = "1.1.13"
version = "1.1.14"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
]
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",
Expand Down
31 changes: 27 additions & 4 deletions src/span_panel_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
),
}

Expand Down Expand Up @@ -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
),
}

Expand All @@ -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
),
}

Expand Down Expand Up @@ -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")
Expand Down