Skip to content

Commit 95f0401

Browse files
committed
feat: identify the SDK in the User-Agent header
- New USER_AGENT constant: 'datamasque-python/<ver> (Python/<py-ver>; <OS>/<rel>)'; version falls back to 'dev' - _build_session sets the User-Agent on the session so every Base/Ifm client call carries it automatically - Lets operators correlate API traffic in access logs with a specific SDK release when triaging reports - Tests cover UA on anon + authed admin-server calls and on IFM login + authenticated requests Closes #10
1 parent 79f462f commit 95f0401

3 files changed

Lines changed: 77 additions & 5 deletions

File tree

datamasque/client/base.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import logging
2+
import platform
3+
import sys
24
import warnings
35
from contextlib import contextmanager
46
from dataclasses import dataclass
7+
from importlib.metadata import PackageNotFoundError, version
58
from io import BufferedIOBase, BytesIO, TextIOBase
69
from pathlib import Path
710
from typing import Any, Callable, Iterator, Optional, Type, TypeVar, Union
@@ -25,16 +28,39 @@
2528
_T = TypeVar("_T", bound=BaseModel)
2629

2730

31+
def _build_user_agent() -> str:
32+
"""
33+
Identify ourselves to the DataMasque server in access logs and audit trails.
34+
35+
Default `python-requests/x.y.z` is anonymous; this surfaces the SDK name +
36+
version, Python interpreter, and OS so operators can correlate API traffic
37+
with a specific SDK release (e.g. when triaging a bug report).
38+
"""
39+
40+
try:
41+
sdk_version = version("datamasque-python")
42+
except PackageNotFoundError:
43+
# Source checkouts without installed metadata.
44+
sdk_version = "dev"
45+
py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
46+
return f"datamasque-python/{sdk_version} (Python/{py}; {platform.system()}/{platform.release()})"
47+
48+
49+
USER_AGENT = _build_user_agent()
50+
51+
2852
def _build_session(verify_ssl: bool) -> requests.Session:
2953
"""
3054
Build a configured `requests.Session` for one client's lifetime.
3155
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.
56+
Centralises the `User-Agent` and `verify` defaults so every call site
57+
inherits them automatically — keeping the per-call code free of
58+
boilerplate and removing the risk of forgetting either flag on a new
59+
endpoint.
3560
"""
3661

3762
session = requests.Session()
63+
session.headers["User-Agent"] = USER_AGENT
3864
session.verify = verify_ssl
3965
return session
4066

@@ -90,8 +116,8 @@ class BaseClient:
90116
Uses a single `requests.Session` for the lifetime of the client so that
91117
per-host TCP / TLS connections are pooled across calls (paginated list
92118
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.
119+
(`User-Agent`, `verify`) are set once on construction; per-call headers
120+
like `Authorization` are merged at request time.
95121
96122
`requests.Session` is not thread-safe; do not share a client between
97123
threads. Construct one per worker.

tests/test_base.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from urllib3.exceptions import InsecureRequestWarning
1111

1212
from datamasque.client import DataMasqueClient, RunId
13+
from datamasque.client.base import USER_AGENT
1314
from datamasque.client.exceptions import (
1415
DataMasqueApiError,
1516
DataMasqueNotReadyError,
@@ -280,6 +281,37 @@ def test_token_source_called_again_on_401_retry():
280281
assert client.token == "Token t2"
281282

282283

284+
def test_user_agent_identifies_the_sdk(client):
285+
"""
286+
Every outgoing request must carry an SDK-identifying User-Agent header.
287+
288+
This lets operators attribute API traffic to a specific SDK release rather
289+
than the generic `python-requests/x.y.z` default.
290+
"""
291+
assert USER_AGENT.startswith("datamasque-python/")
292+
293+
with requests_mock.Mocker() as m:
294+
m.get("http://test-server/api/healthcheck/", json={})
295+
client.healthcheck()
296+
assert m.request_history[0].headers["User-Agent"] == USER_AGENT
297+
298+
299+
def test_user_agent_sent_on_authenticated_requests(client):
300+
"""
301+
The User-Agent must be present on authenticated calls, alongside the auth token.
302+
303+
It is not limited to anonymous requests.
304+
"""
305+
client.token = "Token test-token"
306+
307+
with requests_mock.Mocker() as m:
308+
m.get("http://test-server/api/runs/", json={"results": [], "next": None})
309+
client.make_request("GET", "/api/runs/")
310+
headers = m.request_history[0].headers
311+
assert headers["User-Agent"] == USER_AGENT
312+
assert headers["Authorization"] == "Token test-token"
313+
314+
283315
def test_401_does_not_retry_when_requires_authorization_is_false(client):
284316
"""
285317
A 401 on an anonymous request must surface as-is, not trigger a re-auth retry.

tests/test_ifm.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
RulesetPlanPartialUpdateRequest,
1313
RulesetPlanUpdateRequest,
1414
)
15+
from datamasque.client.base import USER_AGENT
1516
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueUserError
1617

1718
ADMIN = "http://admin.test"
@@ -63,11 +64,24 @@ def test_authenticate_via_jwt_login(ifm_config):
6364
status_code=200,
6465
)
6566
client.authenticate()
67+
assert m.request_history[0].headers["User-Agent"] == USER_AGENT
6668

6769
assert client.access_token == "ACC"
6870
assert client.refresh_token == "REF"
6971

7072

73+
def test_ifm_request_carries_user_agent(authed_ifm_client):
74+
"""
75+
Every IFM call must identify the SDK in the User-Agent header.
76+
77+
Covers login, refresh, and authenticated requests.
78+
"""
79+
with requests_mock.Mocker() as m:
80+
m.get(f"{IFM}/health/", json={"status": "ok"})
81+
authed_ifm_client._make_request("GET", "/health/")
82+
assert m.request_history[0].headers["User-Agent"] == USER_AGENT
83+
84+
7185
def test_authenticate_failure_raises_ifm_auth_error(ifm_config):
7286
client = DataMasqueIfmClient(ifm_config)
7387

0 commit comments

Comments
 (0)