Skip to content

Commit 30f8968

Browse files
lmeilibrclaude
andauthored
fix: Refresh stale HTTP sessions to prevent RemoteDisconnected errors (#66)
Closes idle keep-alive connections after 5 minutes to avoid 'Remote end closed connection without response' errors. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c3322e2 commit 30f8968

1 file changed

Lines changed: 31 additions & 11 deletions

File tree

libzapi/infrastructure/http/client.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,71 @@
1+
import time
2+
13
import requests
24
from requests.adapters import HTTPAdapter
35
from urllib3.util.retry import Retry
46

57
from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity
68

9+
CONNECTION_MAX_AGE = 300 # 5 minutes
10+
711

812
class HttpClient:
913
def __init__(self, base_url: str, headers: dict[str, str], timeout: float = 30.0) -> None:
1014
self.base_url = base_url.rstrip("/")
11-
self.session = requests.Session()
12-
self.session.headers.update(
13-
{
14-
"Accept": "application/json",
15-
"Content-Type": "application/json",
16-
**headers,
17-
}
18-
)
19-
retry = Retry(
15+
self._headers = {
16+
"Accept": "application/json",
17+
"Content-Type": "application/json",
18+
**headers,
19+
}
20+
self._retry = Retry(
2021
total=5,
2122
backoff_factor=0.3,
2223
status_forcelist=[429, 500, 502, 503, 504],
2324
respect_retry_after_header=True,
2425
)
25-
adapter = HTTPAdapter(max_retries=retry)
26-
self.session.mount("https://", adapter)
2726
self.timeout = timeout
27+
self._last_refresh = 0.0
28+
self.session = self._new_session()
29+
30+
def _new_session(self) -> requests.Session:
31+
session = requests.Session()
32+
session.headers.update(self._headers)
33+
adapter = HTTPAdapter(max_retries=self._retry)
34+
session.mount("https://", adapter)
35+
self._last_refresh = time.monotonic()
36+
return session
37+
38+
def _refresh_if_stale(self) -> None:
39+
if time.monotonic() - self._last_refresh > CONNECTION_MAX_AGE:
40+
self.session.close()
41+
self.session = self._new_session()
2842

2943
def get(self, path: str) -> dict:
44+
self._refresh_if_stale()
3045
resp = self.session.get(f"{self.base_url}{path}", timeout=self.timeout)
3146
self._raise(resp)
3247
return resp.json()
3348

3449
def post(self, path: str, json: dict) -> dict:
50+
self._refresh_if_stale()
3551
resp = self.session.post(f"{self.base_url}{path}", json=json, timeout=self.timeout)
3652
self._raise(resp)
3753
return resp.json()
3854

3955
def put(self, path: str, json: dict) -> dict:
56+
self._refresh_if_stale()
4057
resp = self.session.put(f"{self.base_url}{path}", json=json, timeout=self.timeout)
4158
self._raise(resp)
4259
return resp.json()
4360

4461
def patch(self, path: str, json: dict) -> dict:
62+
self._refresh_if_stale()
4563
resp = self.session.patch(f"{self.base_url}{path}", json=json, timeout=self.timeout)
4664
self._raise(resp)
4765
return resp.json()
4866

4967
def post_multipart(self, path: str, files: dict, data: dict | None = None) -> dict:
68+
self._refresh_if_stale()
5069
resp = self.session.post(
5170
f"{self.base_url}{path}",
5271
files=files,
@@ -58,6 +77,7 @@ def post_multipart(self, path: str, files: dict, data: dict | None = None) -> di
5877
return resp.json()
5978

6079
def delete(self, path: str) -> None:
80+
self._refresh_if_stale()
6181
resp = self.session.delete(f"{self.base_url}{path}", timeout=self.timeout)
6282
self._raise(resp)
6383

0 commit comments

Comments
 (0)