|
3 | 3 | import asyncio |
4 | 4 | import io |
5 | 5 | import json |
| 6 | +import platform |
6 | 7 | from datetime import datetime, timezone |
7 | 8 | from email.parser import BytesParser |
8 | 9 | from email.policy import default |
|
28 | 29 | PermissionDeniedError, |
29 | 30 | RateLimitError, |
30 | 31 | UnprocessableEntityError, |
| 32 | + __version__, |
31 | 33 | ) |
32 | 34 |
|
33 | 35 |
|
@@ -100,6 +102,43 @@ def test_query_arrays_and_special_headers(): |
100 | 102 | assert json.loads(requests[1].content) == {"events": []} |
101 | 103 |
|
102 | 104 |
|
| 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 | + |
103 | 142 | def test_datetime_and_nested_query_serialization(): |
104 | 143 | requests = [] |
105 | 144 | with make_client(lambda request: requests.append(request) or httpx.Response(200, json={"data": []})) as client: |
@@ -210,6 +249,22 @@ def handle(request): |
210 | 249 | assert len(calls) == 1 |
211 | 250 |
|
212 | 251 |
|
| 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 | + |
213 | 268 | def test_connection_timeout_and_non_json_error(monkeypatch): |
214 | 269 | monkeypatch.setattr("qca.common._base_client.time.sleep", lambda _: None) |
215 | 270 | for exc_type, expected in [(httpx.ConnectError, APIConnectionError), (httpx.ReadTimeout, APITimeoutError)]: |
@@ -319,7 +374,10 @@ def handle(request): |
319 | 374 | requests.append(request) |
320 | 375 | if request.url.host == "api.test": |
321 | 376 | 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 | + ) |
323 | 381 | return httpx.Response(200, content=b"content") |
324 | 382 |
|
325 | 383 | http = httpx.Client( |
@@ -486,3 +544,21 @@ def handle(request): |
486 | 544 | assert len(calls) == count |
487 | 545 | assert sleeps == [0.025] * (count - 1) |
488 | 546 | 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