Skip to content

Commit a8ddadb

Browse files
authored
Merge pull request #11 from datamasque/feat/session-pooling
Don't retry all 401's and add a session handler
2 parents 2e887da + c7b35ab commit a8ddadb

3 files changed

Lines changed: 83 additions & 32 deletions

File tree

datamasque/client/base.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@
2424
FileOrContent = Union[str, bytes, TextIOBase, BufferedIOBase, Path]
2525
_T = TypeVar("_T", bound=BaseModel)
2626

27+
28+
def _build_session(verify_ssl: bool) -> requests.Session:
29+
"""
30+
Build a configured `requests.Session` for one client's lifetime.
31+
32+
Centralises the `verify` default so every call site inherits it
33+
automatically — keeping the per-call code free of boilerplate and removing
34+
the risk of forgetting the flag on a new endpoint.
35+
"""
36+
37+
session = requests.Session()
38+
session.verify = verify_ssl
39+
return session
40+
41+
2742
# Substrings (case-insensitive) that mark a key whose value should be redacted
2843
# before logging on an error path, so that passwords, API tokens, and similar secrets don't
2944
# end up in user-visible logs when a request fails.
@@ -71,6 +86,15 @@ class BaseClient:
7186
7287
Holds the connection config, cached auth token, and the core `make_request` dispatcher
7388
used by all per-feature mixins that compose `DataMasqueClient`.
89+
90+
Uses a single `requests.Session` for the lifetime of the client so that
91+
per-host TCP / TLS connections are pooled across calls (paginated list
92+
endpoints and tight polling loops benefit most). Session-wide defaults
93+
(`verify`) are set once on construction; per-call headers like
94+
`Authorization` are merged at request time.
95+
96+
`requests.Session` is not thread-safe; do not share a client between
97+
threads. Construct one per worker.
7498
"""
7599

76100
token: str = ""
@@ -86,6 +110,7 @@ def __init__(self, connection_config: DataMasqueInstanceConfig) -> None:
86110
self.password = connection_config.password
87111
self.verify_ssl = connection_config.verify_ssl
88112
self.token_source = connection_config.token_source
113+
self._session = _build_session(self.verify_ssl)
89114

90115
@contextmanager
91116
def _maybe_suppress_insecure_warning(self) -> Iterator[None]:
@@ -186,28 +211,32 @@ def make_request(
186211
url = urljoin(self.base_url, path)
187212

188213
def send() -> Response:
189-
headers: Optional[dict] = {"Authorization": self.token} if requires_authorization else None
214+
headers = {"Authorization": self.token} if requires_authorization else None
190215
try:
191216
with self._maybe_suppress_insecure_warning():
192217
if files:
193218
files_payload = {f.field_name: (f.filename, f.content, f.content_type or "") for f in files}
194-
return requests.request(
219+
return self._session.request(
195220
method,
196221
url,
197222
data=data,
198223
params=params,
199224
headers=headers,
200225
files=files_payload,
201-
verify=self.verify_ssl,
202226
)
203-
return requests.request(
204-
method, url, json=data, params=params, headers=headers, verify=self.verify_ssl
205-
)
227+
return self._session.request(method, url, json=data, params=params, headers=headers)
206228
except requests.RequestException as e:
207229
raise DataMasqueTransportError(f"Failed to reach DataMasque server at {url}: {e}") from e
208230

209231
response = send()
210-
if response.status_code == 401:
232+
if response.status_code == 401 and requires_authorization:
233+
# Token-expiry recovery: re-auth and replay. Only meaningful when the
234+
# caller actually sent a token; on `requires_authorization=False`
235+
# calls a 401 means the server itself is rejecting anonymous access
236+
# (e.g. admin-install on an already-configured instance), and
237+
# re-authing with whatever creds the client happens to hold would
238+
# both misdiagnose the failure and emit a misleading
239+
# "credentials are incorrect" error to the user.
211240
logger.debug("Re-authenticating")
212241
self.authenticate()
213242
# Reset file pointers so the retry doesn't send empty files

datamasque/client/ifm.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from pydantic import BaseModel
1818
from requests import Response
1919

20-
from datamasque.client.base import suppress_insecure_warning_if_needed
20+
from datamasque.client.base import _build_session, suppress_insecure_warning_if_needed
2121
from datamasque.client.exceptions import (
2222
DataMasqueApiError,
2323
DataMasqueNotReadyError,
@@ -82,6 +82,9 @@ def __init__(self, connection_config: DataMasqueIfmInstanceConfig) -> None:
8282
self.password = connection_config.password
8383
self.verify_ssl = connection_config.verify_ssl
8484
self.token_source = connection_config.token_source
85+
# One session for both admin-server (JWT login/refresh) and IFM (data plane)
86+
# traffic -- different hosts, but a single session handles per-host pooling.
87+
self._session = _build_session(self.verify_ssl)
8588

8689
def authenticate(self) -> None:
8790
"""Obtain an access (and refresh) token from the admin server, or via `token_source`."""
@@ -95,10 +98,9 @@ def authenticate(self) -> None:
9598
login_url = urljoin(self.admin_server_base_url, "/api/auth/jwt/login/")
9699
try:
97100
with self._maybe_suppress_insecure_warning():
98-
response = requests.post(
101+
response = self._session.post(
99102
login_url,
100103
json={"username": self.username, "password": self.password},
101-
verify=self.verify_ssl,
102104
)
103105
except requests.RequestException as e:
104106
raise DataMasqueTransportError(f"Failed to reach admin server at {login_url}: {e}") from e
@@ -122,10 +124,9 @@ def _refresh_or_reauth(self) -> None:
122124
refresh_url = urljoin(self.admin_server_base_url, "/api/auth/jwt/refresh/")
123125
try:
124126
with self._maybe_suppress_insecure_warning():
125-
response = requests.post(
127+
response = self._session.post(
126128
refresh_url,
127129
json={"refresh": self.refresh_token},
128-
verify=self.verify_ssl,
129130
)
130131
except requests.RequestException as e:
131132
raise DataMasqueTransportError(f"Failed to reach admin server at {refresh_url}: {e}") from e
@@ -187,13 +188,12 @@ def _make_request(
187188
def send() -> Response:
188189
try:
189190
with self._maybe_suppress_insecure_warning():
190-
return requests.request(
191+
return self._session.request(
191192
method,
192193
url,
193194
json=json_body,
194195
params=params,
195196
headers={"Authorization": f"Bearer {self.access_token}"},
196-
verify=self.verify_ssl,
197197
)
198198
except requests.RequestException as e:
199199
raise DataMasqueTransportError(f"Failed to reach IFM server at {url}: {e}") from e

tests/test_base.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,12 @@ def test_healthcheck_transport_failure(client):
7474

7575

7676
@pytest.mark.parametrize("verify_ssl", [True, False])
77-
def test_make_request_verify_ssl_true_by_default(config, verify_ssl):
78-
"""Verifies SSL setting is passed through to the `requests` call."""
77+
def test_session_verify_reflects_config(config, verify_ssl):
78+
"""
79+
`verify_ssl` is applied to the client's `requests.Session` once at construction.
80+
81+
Every outgoing call then inherits it without per-call boilerplate.
82+
"""
7983
config_with_ssl = DataMasqueInstanceConfig(
8084
base_url=config.base_url,
8185
username=config.username,
@@ -84,24 +88,14 @@ def test_make_request_verify_ssl_true_by_default(config, verify_ssl):
8488
)
8589
client = DataMasqueClient(config_with_ssl)
8690

87-
with patch(
88-
"datamasque.client.base.requests.request",
89-
return_value=make_ok_response(),
90-
) as mock_request:
91-
client.make_request("GET", "/api/test/")
92-
93-
_, kwargs = mock_request.call_args
94-
assert kwargs["verify"] is verify_ssl
91+
assert client._session.verify is verify_ssl
9592

9693

9794
def test_make_request_verify_ssl_true_does_not_touch_global_warning_filter(client):
9895
"""With `verify_ssl=True`, the client should not modify `warnings.filters`."""
9996
filters_before = list(warnings.filters)
10097

101-
with patch(
102-
"datamasque.client.base.requests.request",
103-
return_value=make_ok_response(),
104-
):
98+
with patch.object(client._session, "request", return_value=make_ok_response()):
10599
client.make_request("GET", "/api/test/")
106100

107101
assert warnings.filters == filters_before
@@ -125,10 +119,7 @@ def raise_insecure_warning_then_respond(*_args, **_kwargs):
125119

126120
with warnings.catch_warnings(record=True) as captured:
127121
warnings.simplefilter("always") # ensure we'd otherwise see the warning
128-
with patch(
129-
"datamasque.client.base.requests.request",
130-
side_effect=raise_insecure_warning_then_respond,
131-
):
122+
with patch.object(client._session, "request", side_effect=raise_insecure_warning_then_respond):
132123
client.make_request("GET", "/api/test/")
133124

134125
# The warning raised inside the request call was suppressed by the client.
@@ -289,6 +280,37 @@ def test_token_source_called_again_on_401_retry():
289280
assert client.token == "Token t2"
290281

291282

283+
def test_401_does_not_retry_when_requires_authorization_is_false(client):
284+
"""
285+
A 401 on an anonymous request must surface as-is, not trigger a re-auth retry.
286+
287+
`/api/users/admin-install/` returns 401 once any user exists -- the endpoint
288+
is gated on "no user has been created yet" and DRF treats it as a normal
289+
auth-required endpoint thereafter. Re-authing on that 401 would both
290+
misdiagnose the failure ("login credentials are correct") and waste a
291+
round-trip on a call the caller said doesn't need auth.
292+
"""
293+
with requests_mock.Mocker() as m:
294+
m.post(
295+
"http://test-server/api/users/admin-install/",
296+
status_code=401,
297+
json={"detail": "Authentication credentials were not provided."},
298+
)
299+
300+
with pytest.raises(DataMasqueApiError) as excinfo:
301+
client.make_request(
302+
"POST",
303+
"/api/users/admin-install/",
304+
data={"email": "x@y", "username": "x", "password": "p", "re_password": "p", "allowed_hosts": []},
305+
requires_authorization=False,
306+
)
307+
308+
assert excinfo.value.response.status_code == 401
309+
# Exactly one request: no re-auth roundtrip to /api/auth/token/login/ and no replay.
310+
assert m.call_count == 1
311+
assert m.request_history[0].path == "/api/users/admin-install/"
312+
313+
292314
def test_token_source_callable_exception_propagates():
293315
"""Errors from `token_source` are surfaced to the caller, not swallowed."""
294316

0 commit comments

Comments
 (0)