From 820bf25f412c7beb48dd7f0d134e28e98c640aac Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Mon, 13 Jul 2026 16:00:01 -0400 Subject: [PATCH 1/2] Send a User-Agent identifying the Python SDK on every call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set User-Agent: aleo-python-sdk/ on all SDK requests — reads, JWT refresh, the prover handshake, and every scanner call — so the SDK is identifiable in standard access logs (overriding the python-requests/httpx default). Injected at the two per-request header layers (network client's method_headers and the scanner's _build_headers, sync + async, including their JWT-refresh sub-requests). Registered in SDK_HEADERS so it's suppressed when the caller supplies their own transport, matching the existing x-aleo-* headers. Co-Authored-By: Claude Opus 4.8 --- sdk/python/aleo/_client_common.py | 20 ++++++++- sdk/python/aleo/async_record_scanner.py | 10 ++++- sdk/python/aleo/record_scanner.py | 9 +++- sdk/python/tests/test_network_client.py | 11 +++++ sdk/python/tests/test_network_client_async.py | 2 + sdk/python/tests/test_record_scanner.py | 40 +++++++++++++++++ sdk/python/tests/test_record_scanner_async.py | 45 +++++++++++++++++++ 7 files changed, 133 insertions(+), 4 deletions(-) diff --git a/sdk/python/aleo/_client_common.py b/sdk/python/aleo/_client_common.py index 56dfb6e1..867f7450 100644 --- a/sdk/python/aleo/_client_common.py +++ b/sdk/python/aleo/_client_common.py @@ -49,7 +49,12 @@ def __init__(self, message: str, status: int | None = None) -> None: def is_provable_host(url: str) -> bool: """True if *url* points at the hosted Provable API (api.provable.com).""" return (urlparse(url).hostname or "").lower() in PROVABLE_API_HOSTS -SDK_HEADERS: set[str] = {"x-aleo-sdk-version", "x-aleo-environment", "x-aleo-method"} +SDK_HEADERS: set[str] = { + "x-aleo-sdk-version", + "x-aleo-environment", + "x-aleo-method", + "user-agent", +} def package_version() -> str: @@ -60,6 +65,17 @@ def package_version() -> str: return "0.0.0" +def user_agent() -> str: + """The SDK's ``User-Agent`` string, sent on every call. + + Identifies the Python SDK (and its version) in the standard, always-logged + header, overriding the underlying ``python-requests`` / ``python-httpx`` + default. Treated as an SDK header (see :data:`SDK_HEADERS`), so it is + suppressed when the caller supplies their own transport. + """ + return f"aleo-python-sdk/{package_version()}" + + def make_default_headers() -> dict[str, str]: return { "X-Aleo-SDK-Version": package_version(), @@ -79,7 +95,7 @@ def method_headers( ) -> dict[str, str]: if has_custom_transport: return user_headers(headers) - return {**headers, "X-ALEO-METHOD": method} + return {**headers, "X-ALEO-METHOD": method, "User-Agent": user_agent()} def jwt_origin(host: str) -> str: diff --git a/sdk/python/aleo/async_record_scanner.py b/sdk/python/aleo/async_record_scanner.py index 9ba94d14..b4d39b53 100644 --- a/sdk/python/aleo/async_record_scanner.py +++ b/sdk/python/aleo/async_record_scanner.py @@ -6,7 +6,7 @@ from typing import Any from urllib.parse import urlparse -from ._client_common import jwt_expired +from ._client_common import jwt_expired, user_agent from ._scanner_common import ( DecryptionNotEnabledError, OwnedFilter, @@ -104,6 +104,7 @@ def __init__( self._account: Any | None = None # Build httpx client + self._has_custom_transport: bool = transport is not None if transport is not None: self._client: Any = httpx.AsyncClient(transport=transport) else: @@ -174,6 +175,11 @@ def set_account(self, account: Any) -> None: async def _build_headers(self) -> dict[str, str]: """Build authentication headers, refreshing JWT if needed.""" hdrs: dict[str, str] = {"Content-Type": "application/json"} + # Identify the SDK on every call — unless a custom transport owns the + # HTTP layer, in which case the caller controls headers. + sdk_ua = None if self._has_custom_transport else user_agent() + if sdk_ua: + hdrs["User-Agent"] = sdk_ua if self._api_key: hdrs[self._api_key["header"]] = self._api_key["value"] @@ -183,6 +189,8 @@ async def _build_headers(self) -> dict[str, str]: if self._api_key and self.consumer_id: jwt_url = f"{self._origin}/jwts/{self.consumer_id}" jwt_hdrs = {self._api_key["header"]: self._api_key["value"]} + if sdk_ua: + jwt_hdrs["User-Agent"] = sdk_ua resp = await self._client.post(jwt_url, headers=jwt_hdrs) if resp.is_success: auth = resp.headers.get("Authorization") or resp.headers.get("authorization") diff --git a/sdk/python/aleo/record_scanner.py b/sdk/python/aleo/record_scanner.py index b70cf80f..40eb2289 100644 --- a/sdk/python/aleo/record_scanner.py +++ b/sdk/python/aleo/record_scanner.py @@ -7,7 +7,7 @@ import requests -from ._client_common import jwt_expired +from ._client_common import jwt_expired, user_agent from ._scanner_common import ( DecryptionNotEnabledError, OwnedFilter, @@ -120,6 +120,11 @@ def _http(self, method: str, url: str, **kwargs: Any) -> requests.Response: def _build_headers(self) -> dict[str, str]: """Build authentication headers, refreshing JWT if needed.""" hdrs: dict[str, str] = {"Content-Type": "application/json"} + # Identify the SDK on every call — unless a custom transport owns the + # HTTP layer, in which case the caller controls headers. + sdk_ua = None if callable(self._transport) else user_agent() + if sdk_ua: + hdrs["User-Agent"] = sdk_ua # Always attach api_key header if set if self._api_key: @@ -132,6 +137,8 @@ def _build_headers(self) -> dict[str, str]: # Refresh JWT jwt_url = f"{self._origin}/jwts/{self.consumer_id}" jwt_hdrs = {self._api_key["header"]: self._api_key["value"]} + if sdk_ua: + jwt_hdrs["User-Agent"] = sdk_ua resp = self._http("POST", jwt_url, headers=jwt_hdrs) if resp.ok: auth = resp.headers.get("Authorization") or resp.headers.get("authorization") diff --git a/sdk/python/tests/test_network_client.py b/sdk/python/tests/test_network_client.py index f579e67a..00382049 100644 --- a/sdk/python/tests/test_network_client.py +++ b/sdk/python/tests/test_network_client.py @@ -233,6 +233,9 @@ def test_default_sdk_headers_present() -> None: assert "X-Aleo-SDK-Version" in req_headers assert "X-Aleo-environment" in req_headers assert req_headers["X-Aleo-environment"] == "python" + # The standard User-Agent identifies the SDK (overriding requests' default). + from aleo._client_common import package_version + assert req_headers["User-Agent"] == f"aleo-python-sdk/{package_version()}" @resp_lib.activate @@ -244,6 +247,11 @@ def test_per_method_header() -> None: assert req_headers.get("X-ALEO-METHOD") == "getBlock" +def test_user_agent_value() -> None: + from aleo._client_common import package_version, user_agent + assert user_agent() == f"aleo-python-sdk/{package_version()}" + + def test_custom_transport_callable_used_for_requests() -> None: """A callable transport is invoked for every HTTP request.""" import requests as _requests @@ -273,6 +281,9 @@ def test_custom_transport_suppresses_sdk_headers() -> None: req_headers = resp_lib.calls[0].request.headers assert "X-Aleo-SDK-Version" not in req_headers assert "X-ALEO-METHOD" not in req_headers + # UA is suppressed under a custom transport: the SDK does not set its own + # (requests' own default python-requests/... may still be present). + assert not req_headers.get("User-Agent", "").startswith("aleo-python-sdk/") @resp_lib.activate diff --git a/sdk/python/tests/test_network_client_async.py b/sdk/python/tests/test_network_client_async.py index 0f6d921b..6d4f8171 100644 --- a/sdk/python/tests/test_network_client_async.py +++ b/sdk/python/tests/test_network_client_async.py @@ -249,6 +249,8 @@ def handler(req: httpx.Request) -> httpx.Response: await c.get_latest_block() assert "X-Aleo-SDK-Version" in captured[0].headers assert captured[0].headers.get("X-Aleo-environment") == "python" + from aleo._client_common import package_version + assert captured[0].headers.get("User-Agent") == f"aleo-python-sdk/{package_version()}" @pytest.mark.asyncio diff --git a/sdk/python/tests/test_record_scanner.py b/sdk/python/tests/test_record_scanner.py index ec6fee47..920ed574 100644 --- a/sdk/python/tests/test_record_scanner.py +++ b/sdk/python/tests/test_record_scanner.py @@ -662,3 +662,43 @@ def test_find_record_returns_first() -> None: record = scanner.find_record({"uuid": GOLDEN_UUID}) # type: ignore[arg-type] assert record == OWNED_RECORDS[0] + + +# --------------------------------------------------------------------------- +# User-Agent header +# --------------------------------------------------------------------------- + +@resp_lib.activate +def test_scanner_sends_user_agent() -> None: + """Every scanner call carries the SDK User-Agent.""" + from aleo.mainnet import Field + from aleo._client_common import package_version + + resp_lib.add(resp_lib.POST, f"{HOST}/records/owned", json=[]) + scanner = _make_scanner() + scanner._uuid = Field.from_string(GOLDEN_UUID) + scanner.owned({"uuid": GOLDEN_UUID, "unspent": True}) # type: ignore[arg-type] + + hdrs = resp_lib.calls[0].request.headers + assert hdrs["User-Agent"] == f"aleo-python-sdk/{package_version()}" + + +def test_scanner_custom_transport_suppresses_user_agent() -> None: + """A custom transport owns the HTTP layer — the SDK sets no User-Agent.""" + import requests as _requests + from aleo.mainnet import Field + + captured: dict[str, Any] = {} + + def transport(method: str, url: str, **kwargs: Any) -> _requests.Response: + captured["headers"] = dict(kwargs.get("headers") or {}) + r = _requests.Response() + r.status_code = 200 + r._content = b"[]" + return r + + scanner = _make_scanner(transport=transport) + scanner._uuid = Field.from_string(GOLDEN_UUID) + scanner.owned({"uuid": GOLDEN_UUID, "unspent": True}) # type: ignore[arg-type] + + assert "User-Agent" not in captured["headers"] diff --git a/sdk/python/tests/test_record_scanner_async.py b/sdk/python/tests/test_record_scanner_async.py index d9dd6eab..5b2f58cd 100644 --- a/sdk/python/tests/test_record_scanner_async.py +++ b/sdk/python/tests/test_record_scanner_async.py @@ -429,3 +429,48 @@ async def test_async_find_credits_records_success() -> None: result3 = await scanner3.find_credits_records([999], {}) # type: ignore[arg-type] assert result3 == [] + + +# --------------------------------------------------------------------------- +# User-Agent header +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_async_scanner_sends_user_agent() -> None: + """Async scanner carries the SDK User-Agent when it owns the transport.""" + from aleo.mainnet import Field + from aleo._client_common import package_version + + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["ua"] = request.headers.get("user-agent") + return httpx.Response(200, json=[]) + + # No transport passed to __init__ (so it is NOT treated as a custom + # transport); swap the client for a mock one, mirroring the network-client + # test pattern. + scanner = AsyncRecordScanner(BASE_URL) + scanner._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + scanner._uuid = Field.from_string(GOLDEN_UUID) + await scanner.owned({"uuid": GOLDEN_UUID, "unspent": True}) # type: ignore[arg-type] + + assert captured["ua"] == f"aleo-python-sdk/{package_version()}" + + +@pytest.mark.asyncio +async def test_async_scanner_custom_transport_suppresses_user_agent() -> None: + """A custom transport (passed to __init__) suppresses the SDK User-Agent.""" + from aleo.mainnet import Field + + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["ua"] = request.headers.get("user-agent") + return httpx.Response(200, json=[]) + + scanner = AsyncRecordScanner(BASE_URL, transport=httpx.MockTransport(handler)) + scanner._uuid = Field.from_string(GOLDEN_UUID) + await scanner.owned({"uuid": GOLDEN_UUID, "unspent": True}) # type: ignore[arg-type] + + assert not (captured["ua"] or "").startswith("aleo-python-sdk/") From b927b15b274a2ed6b33fead2d2730312e5ccf7c8 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Mon, 13 Jul 2026 16:08:46 -0400 Subject: [PATCH 2/2] Don't strip a caller's User-Agent under a custom transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop "user-agent" from SDK_HEADERS. The UA is only ever added on the default transport (in method_headers' non-custom branch), so it was never present to "suppress" under a custom transport — the only effect of the SDK_HEADERS entry was to delete a User-Agent the CALLER set, which is the opposite of hands-off. With a custom transport the SDK now layers nothing of its own; the caller's headers pass through untouched. Adds a regression test. Co-Authored-By: Claude Opus 4.8 --- sdk/python/aleo/_client_common.py | 12 ++++-------- sdk/python/tests/test_network_client.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/sdk/python/aleo/_client_common.py b/sdk/python/aleo/_client_common.py index 867f7450..6d65450b 100644 --- a/sdk/python/aleo/_client_common.py +++ b/sdk/python/aleo/_client_common.py @@ -49,12 +49,7 @@ def __init__(self, message: str, status: int | None = None) -> None: def is_provable_host(url: str) -> bool: """True if *url* points at the hosted Provable API (api.provable.com).""" return (urlparse(url).hostname or "").lower() in PROVABLE_API_HOSTS -SDK_HEADERS: set[str] = { - "x-aleo-sdk-version", - "x-aleo-environment", - "x-aleo-method", - "user-agent", -} +SDK_HEADERS: set[str] = {"x-aleo-sdk-version", "x-aleo-environment", "x-aleo-method"} def package_version() -> str: @@ -70,8 +65,9 @@ def user_agent() -> str: Identifies the Python SDK (and its version) in the standard, always-logged header, overriding the underlying ``python-requests`` / ``python-httpx`` - default. Treated as an SDK header (see :data:`SDK_HEADERS`), so it is - suppressed when the caller supplies their own transport. + default. Only injected on the default transport (see :func:`method_headers` + and the scanners' header builders); when the caller supplies their own + transport they own the headers, so the SDK does not set it. """ return f"aleo-python-sdk/{package_version()}" diff --git a/sdk/python/tests/test_network_client.py b/sdk/python/tests/test_network_client.py index 00382049..5c25cb8f 100644 --- a/sdk/python/tests/test_network_client.py +++ b/sdk/python/tests/test_network_client.py @@ -286,6 +286,27 @@ def test_custom_transport_suppresses_sdk_headers() -> None: assert not req_headers.get("User-Agent", "").startswith("aleo-python-sdk/") +def test_custom_transport_preserves_user_supplied_user_agent() -> None: + """Under a custom transport the caller owns headers — a User-Agent they set + is passed through untouched (not stripped as an SDK header).""" + import requests as _requests + + captured: dict[str, Any] = {} + + def my_transport(method: str, url: str, **kwargs: Any) -> _requests.Response: + captured["headers"] = dict(kwargs.get("headers") or {}) + r = _requests.Response() + r.status_code = 200 + r._content = b"{}" + return r + + c = AleoNetworkClient( + BASE, network=NET, transport=my_transport, headers={"User-Agent": "myapp/1.0"} + ) + c.get_latest_block() + assert captured["headers"].get("User-Agent") == "myapp/1.0" + + @resp_lib.activate def test_set_header() -> None: resp_lib.add(resp_lib.GET, f"{HOST}/block/latest", json={})