Skip to content

Commit 64107ad

Browse files
authored
Merge pull request #88 from SpanPanel/remote_protocol_error
Recognize panel Keep-Alive at 5 sec, Handle httpx.RemoteProtocolError…
2 parents bb58605 + c83560c commit 64107ad

5 files changed

Lines changed: 40 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
- name: Install Poetry
2626
uses: snok/install-poetry@v1
2727
with:
28-
version: latest
28+
version: 2.1.4
2929
virtualenvs-create: true
3030
virtualenvs-in-project: true
3131

@@ -67,7 +67,7 @@ jobs:
6767
- name: Install Poetry
6868
uses: snok/install-poetry@v1
6969
with:
70-
version: latest
70+
version: 2.1.4
7171
virtualenvs-create: true
7272
virtualenvs-in-project: true
7373

@@ -101,7 +101,7 @@ jobs:
101101
- name: Install Poetry
102102
uses: snok/install-poetry@v1
103103
with:
104-
version: latest
104+
version: 2.1.4
105105
virtualenvs-create: true
106106
virtualenvs-in-project: true
107107

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
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).
66

7+
## [1.1.14] - 12/2025
8+
9+
### Fixed in v1.1.14
10+
11+
- Recognize panel Keep-Alive at 5 sec, Handle httpx.RemoteProtocolError defensively
12+
713
## [1.1.9] - 9/2025
814

915
### Fixed in v1.1.9

poetry.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
[project]
22
name = "span-panel-api"
3-
version = "1.1.13"
3+
version = "1.1.14"
44
description = "A client library for SPAN Panel API"
55
authors = [
66
{name = "SpanPanel"}
77
]
88
readme = "README.md"
99
requires-python = ">=3.10,<4.0"
1010
dependencies = [
11-
"httpx>=0.20.0,<0.29.0",
11+
"httpx>=0.28.1,<0.29.0",
1212
"attrs>=22.2.0",
1313
"python-dateutil>=2.8.0",
1414
"click>=8.0.0",

src/span_panel_api/client.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,10 @@ async def __aenter__(self) -> SpanPanelClient:
235235
self._client = self._get_unauthenticated_client()
236236

237237
# Enter the httpx client context
238+
# Must manually call __aenter__ - can't use async with because we need the client
239+
# to stay open until __aexit__ is called (split context management pattern)
238240
try:
239-
await self._client.__aenter__()
241+
await self._client.__aenter__() # pylint: disable=unnecessary-dunder-call
240242
except Exception as e:
241243
# Reset state on failure
242244
self._client = None
@@ -448,7 +450,7 @@ def _get_client(self) -> AuthenticatedClient | Client:
448450
"limits": httpx.Limits(
449451
max_keepalive_connections=5, # Keep connections alive
450452
max_connections=10, # Allow multiple connections
451-
keepalive_expiry=30.0, # Keep connections alive for 30 seconds
453+
keepalive_expiry=4.0, # Close before server's 5s keep-alive timeout
452454
),
453455
}
454456

@@ -480,7 +482,7 @@ def _get_unauthenticated_client(self) -> Client:
480482
"limits": httpx.Limits(
481483
max_keepalive_connections=5, # Keep connections alive
482484
max_connections=10, # Allow multiple connections
483-
keepalive_expiry=30.0, # Keep connections alive for 30 seconds
485+
keepalive_expiry=4.0, # Close before server's 5s keep-alive timeout
484486
),
485487
}
486488

@@ -506,7 +508,7 @@ def _get_authenticated_client(self) -> AuthenticatedClient:
506508
"limits": httpx.Limits(
507509
max_keepalive_connections=5, # Keep connections alive
508510
max_connections=10, # Allow multiple connections
509-
keepalive_expiry=30.0, # Keep connections alive for 30 seconds
511+
keepalive_expiry=4.0, # Close before server's 5s keep-alive timeout
510512
),
511513
}
512514

@@ -696,6 +698,27 @@ async def _retry_with_backoff(self, operation: Callable[..., Awaitable[T]], *arg
696698
continue
697699
# Last attempt - re-raise
698700
raise
701+
except httpx.RemoteProtocolError:
702+
# Server closed connection (stale keep-alive) - all pooled connections likely dead
703+
# Destroy client to force fresh connection pool on retry
704+
if self._client is not None:
705+
with suppress(Exception):
706+
await self._client.__aexit__(None, None, None)
707+
self._client = None
708+
709+
# If in context mode, recreate client to maintain invariant that _client is not None
710+
if self._in_context:
711+
if self._access_token:
712+
self._client = self._get_authenticated_client()
713+
else:
714+
self._client = self._get_unauthenticated_client()
715+
# Must manually enter context - can't use async with here as we're already in a context
716+
# and need to keep client alive for retry. This matches the pattern in __aenter__ (line 239).
717+
await self._client.__aenter__() # pylint: disable=unnecessary-dunder-call
718+
719+
if attempt < max_attempts - 1:
720+
continue # Immediate retry - no delay needed
721+
raise
699722

700723
# This should never be reached, but required for mypy type checking
701724
raise SpanPanelAPIError("Retry operation completed without success or exception")

0 commit comments

Comments
 (0)