Skip to content

Commit bc18a1f

Browse files
committed
feat(common): report the client fingerprint like the Go SDK
The server cannot tell which SDK, version or platform a request came from, so breaking changes can only be assessed after customers report them. Send the same six identity headers the Go SDK already sends, plus the per-request retry count and deadline, using its spellings so the two languages aggregate into the same buckets.
1 parent 409ec3b commit bc18a1f

3 files changed

Lines changed: 152 additions & 3 deletions

File tree

src/qca/common/_base_client.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ._exceptions import APIConnectionError, APIResponseValidationError, APITimeoutError, status_error
1717
from ._files import multipart_parts
1818
from ._models import parse_response
19+
from ._platform import platform_headers
1920
from ._response import (
2021
APIResponse,
2122
AsyncAPIResponse,
@@ -33,6 +34,15 @@
3334
DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
3435

3536

37+
def _timeout_seconds(timeout: Any) -> int | None:
38+
"""The deadline the server should expect, in whole seconds like the Go SDK."""
39+
if isinstance(timeout, httpx.Timeout):
40+
timeout = timeout.read
41+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
42+
return None
43+
return int(timeout)
44+
45+
3646
class BaseClient:
3747
_client: Any
3848
_default_base_url: str
@@ -111,7 +121,14 @@ def is_closed(self) -> bool:
111121
return self._client.is_closed
112122

113123
def _headers(self, options: dict[str, Any], token: str | None) -> httpx.Headers:
114-
headers = httpx.Headers({"Accept": "application/json", "User-Agent": f"qca-python/{__version__}"})
124+
headers = httpx.Headers(
125+
{
126+
"Accept": "application/json",
127+
"User-Agent": f"qca-python/{__version__}",
128+
"X-Qoder-Retry-Count": "0",
129+
**platform_headers(),
130+
}
131+
)
115132
if token:
116133
headers["Authorization"] = f"Bearer {token}"
117134
for source in (self.default_headers, options.get("headers", {})):
@@ -129,12 +146,17 @@ def _request_args(
129146
) -> dict[str, Any]:
130147
headers = self._headers(options, token)
131148
timeout = options.get("timeout", NOT_GIVEN)
149+
effective_timeout = self.timeout if isinstance(timeout, NotGiven) else timeout
150+
if "X-Qoder-Timeout" not in headers:
151+
seconds = _timeout_seconds(effective_timeout)
152+
if seconds:
153+
headers["X-Qoder-Timeout"] = str(seconds)
132154
args = dict(
133155
method=method,
134156
url=self.base_url.join(path.lstrip("/")),
135157
headers=headers,
136158
params=query_pairs({**self.default_query, **options.get("query", {})}),
137-
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
159+
timeout=effective_timeout,
138160
)
139161
if file_fields:
140162
args["files"] = multipart_parts(options.get("body", {}), file_fields)
@@ -244,7 +266,10 @@ def __init__(
244266
self._client = http_client or httpx.Client(timeout=timeout, follow_redirects=False)
245267

246268
def _send(self, request: httpx.Request, *, storage: bool = False) -> httpx.Response:
269+
track_retries = request.headers.get("X-Qoder-Retry-Count") == "0"
247270
for attempt in range(self.max_retries + 1):
271+
if track_retries and attempt:
272+
request.headers["X-Qoder-Retry-Count"] = str(attempt)
248273
if (
249274
not storage
250275
and self.credential
@@ -359,7 +384,10 @@ def __init__(
359384
self._client = http_client or httpx.AsyncClient(timeout=timeout, follow_redirects=False)
360385

361386
async def _send(self, request: httpx.Request, *, storage: bool = False) -> httpx.Response:
387+
track_retries = request.headers.get("X-Qoder-Retry-Count") == "0"
362388
for attempt in range(self.max_retries + 1):
389+
if track_retries and attempt:
390+
request.headers["X-Qoder-Retry-Count"] = str(attempt)
363391
if (
364392
not storage
365393
and self.credential

src/qca/common/_platform.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
import platform
4+
5+
from qca._version import __version__
6+
7+
# The spellings match the Go SDK's convention/requestconfig.go on purpose: the
8+
# server aggregates SDK usage across languages, so the same machine has to land
9+
# in the same bucket no matter which SDK called. platform.machine() and Go's
10+
# runtime.GOARCH name the same architecture differently, hence the aliases.
11+
_OS_NAMES = {
12+
"darwin": "MacOS",
13+
"windows": "Windows",
14+
"linux": "Linux",
15+
"ios": "iOS",
16+
"android": "Android",
17+
"freebsd": "FreeBSD",
18+
"openbsd": "OpenBSD",
19+
}
20+
21+
_ARCH_NAMES = {
22+
"386": "x32",
23+
"i386": "x32",
24+
"i686": "x32",
25+
"x86": "x32",
26+
"amd64": "x64",
27+
"x86_64": "x64",
28+
"arm": "arm",
29+
"armv7l": "arm",
30+
"arm64": "arm64",
31+
"aarch64": "arm64",
32+
}
33+
34+
35+
def platform_headers() -> dict[str, str]:
36+
system = platform.system().lower()
37+
machine = platform.machine().lower()
38+
return {
39+
"X-Qoder-Lang": "python",
40+
"X-Qoder-Package-Version": __version__,
41+
"X-Qoder-OS": _OS_NAMES.get(system, f"Other:{system or 'unknown'}"),
42+
"X-Qoder-Arch": _ARCH_NAMES.get(machine, f"other:{machine or 'unknown'}"),
43+
"X-Qoder-Runtime": platform.python_implementation(),
44+
"X-Qoder-Runtime-Version": platform.python_version(),
45+
}

tests/test_client.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import io
55
import json
6+
import platform
67
from datetime import datetime, timezone
78
from email.parser import BytesParser
89
from email.policy import default
@@ -28,6 +29,7 @@
2829
PermissionDeniedError,
2930
RateLimitError,
3031
UnprocessableEntityError,
32+
__version__,
3133
)
3234

3335

@@ -100,6 +102,43 @@ def test_query_arrays_and_special_headers():
100102
assert json.loads(requests[1].content) == {"events": []}
101103

102104

105+
def test_client_fingerprint_reports_language_version_platform_and_deadline():
106+
requests = []
107+
with make_client(lambda request: requests.append(request) or httpx.Response(200, json={"data": []})) as client:
108+
client.models.list()
109+
client.models.list(timeout=7)
110+
headers = requests[0].headers
111+
assert headers["user-agent"] == f"qca-python/{__version__}"
112+
assert headers["x-qoder-lang"] == "python"
113+
assert headers["x-qoder-package-version"] == __version__
114+
assert headers["x-qoder-runtime"] == platform.python_implementation()
115+
assert headers["x-qoder-runtime-version"] == platform.python_version()
116+
# Normalized rather than platform.system()/machine() raw, so that the same
117+
# machine lands in the same server-side bucket as the Go and TS SDKs.
118+
assert headers["x-qoder-os"] in {"MacOS", "Windows", "Linux", "iOS", "Android", "FreeBSD", "OpenBSD"}
119+
assert headers["x-qoder-arch"] in {"x32", "x64", "arm", "arm64"}
120+
assert headers["x-qoder-retry-count"] == "0"
121+
assert headers["x-qoder-timeout"] == "60"
122+
assert requests[1].headers["x-qoder-timeout"] == "7"
123+
124+
125+
def test_client_fingerprint_yields_to_caller_and_omits_absent_deadline():
126+
requests = []
127+
128+
def handle(request):
129+
requests.append(request)
130+
return httpx.Response(200, json={"data": []})
131+
132+
with make_client(handle, default_headers={"X-Qoder-Lang": "cli", "X-Qoder-Timeout": "5"}) as client:
133+
client.models.list(extra_headers={"User-Agent": "caller/1.0"})
134+
with make_client(handle) as client:
135+
client.models.list(timeout=None)
136+
assert requests[0].headers["x-qoder-lang"] == "cli"
137+
assert requests[0].headers["x-qoder-timeout"] == "5"
138+
assert requests[0].headers["user-agent"] == "caller/1.0"
139+
assert "x-qoder-timeout" not in requests[1].headers
140+
141+
103142
def test_datetime_and_nested_query_serialization():
104143
requests = []
105144
with make_client(lambda request: requests.append(request) or httpx.Response(200, json={"data": []})) as client:
@@ -210,6 +249,22 @@ def handle(request):
210249
assert len(calls) == 1
211250

212251

252+
def test_retry_count_header_reports_the_attempt_number(monkeypatch):
253+
monkeypatch.setattr("qca.common._base_client.time.sleep", lambda _: None)
254+
counts = []
255+
256+
# Read inside the handler: the request object is reused across attempts, so
257+
# inspecting it afterwards would only show the last value.
258+
def handle(request):
259+
counts.append(request.headers["x-qoder-retry-count"])
260+
return httpx.Response(500, json={})
261+
262+
with make_client(handle) as client:
263+
with pytest.raises(APIStatusError):
264+
client.models.list()
265+
assert counts == ["0", "1", "2"]
266+
267+
213268
def test_connection_timeout_and_non_json_error(monkeypatch):
214269
monkeypatch.setattr("qca.common._base_client.time.sleep", lambda _: None)
215270
for exc_type, expected in [(httpx.ConnectError, APIConnectionError), (httpx.ReadTimeout, APITimeoutError)]:
@@ -319,7 +374,10 @@ def handle(request):
319374
requests.append(request)
320375
if request.url.host == "api.test":
321376
return httpx.Response(200, json={"url": "https://storage.test/asset?signed=true"})
322-
assert not any(k in request.headers for k in ("authorization", "cookie", "x-sensitive", "qoder-workspace-id"))
377+
assert not any(
378+
k in request.headers
379+
for k in ("authorization", "cookie", "x-sensitive", "qoder-workspace-id", "x-qoder-lang")
380+
)
323381
return httpx.Response(200, content=b"content")
324382

325383
http = httpx.Client(
@@ -486,3 +544,21 @@ def handle(request):
486544
assert len(calls) == count
487545
assert sleeps == [0.025] * (count - 1)
488546
assert len({request.content for request in calls}) == 1
547+
548+
549+
async def test_async_retry_count_header_reports_the_attempt_number(monkeypatch):
550+
async def pause(delay):
551+
return None
552+
553+
monkeypatch.setattr("qca.common._base_client.anyio.sleep", pause)
554+
counts = []
555+
556+
def handle(request):
557+
counts.append(request.headers["x-qoder-retry-count"])
558+
return httpx.Response(500, json={})
559+
560+
transport = httpx.MockTransport(handle)
561+
async with AsyncForward(access_token="test", http_client=httpx.AsyncClient(transport=transport)) as client:
562+
with pytest.raises(APIStatusError):
563+
await client.models.list()
564+
assert counts == ["0", "1", "2"]

0 commit comments

Comments
 (0)