From 5f83695a0b23e7184f36d60c3db6764954b2745e Mon Sep 17 00:00:00 2001 From: Michael Adams Date: Sat, 12 Sep 2026 13:13:46 +0200 Subject: [PATCH] fix: replace the generated client on refresh instead of mutating its token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refreshed token never reached the wire. `AuthenticatedClient` writes the Authorization header into an `httpx.Client` the first time it is used and caches that client; `_authenticate()` and `_ensure_client()` then assigned `client.token`, which updates the attribute and nothing else. Every request after the first token expiry carried the expired token. The symptom is severe and silent: the API answers 403 with an explicit-deny message, a caller's retry mints another token and is rejected the same way, and only a process restart recovers. A downstream live runtime lost sixteen hours of fixture discovery to this — 1442 rejected calls in a day — while a one-off script with the same credentials worked. Token changes now discard the client so the next call rebuilds it, and the superseded transport is closed rather than leaked. `close()` releases it too, which it previously never did. The new tests assert the invariant without touching HTTP: the constructor performs no network I/O, and a pre-set expiry keeps `_ensure_client()` from authenticating, so a token swap can be driven directly. Co-Authored-By: Claude Opus 5 --- src/sportradar_datacore_api/api.py | 21 +++++++++-- test/test_token_refresh.py | 60 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 test/test_token_refresh.py diff --git a/src/sportradar_datacore_api/api.py b/src/sportradar_datacore_api/api.py index dcbaeda..9cc7777 100644 --- a/src/sportradar_datacore_api/api.py +++ b/src/sportradar_datacore_api/api.py @@ -125,8 +125,20 @@ def _authenticate(self) -> None: self._expires_at = time.time() + expires_in - self._TOKEN_BUFFER self.session.headers.update({"Authorization": f"Bearer {self._token}"}) - if self.client is not None: - self.client.token = self._token + self._discard_client() + + def _discard_client(self) -> None: + """Drop the generated client so the next call rebuilds its headers. + + `AuthenticatedClient` writes the Authorization header into an + `httpx.Client` the first time it is used and caches that client. + Assigning `.token` afterwards updates the attribute and nothing else, + so the expired token keeps going out on the wire until the process + restarts. A token change has to replace the client, not mutate it. + """ + previous, self.client = self.client, None + if previous is not None: + previous.get_httpx_client().close() def _ensure_token(self) -> None: with self._lock: @@ -140,6 +152,8 @@ def _ensure_client(self) -> AuthenticatedClient: if self._token is None: raise AuthenticationError("Authentication token was not initialized.") token = self._token + # A surviving client always carries the current token: every + # refresh goes through _discard_client(). if self.client is None: self.client = AuthenticatedClient( base_url=self.base_url, @@ -148,8 +162,6 @@ def _ensure_client(self) -> AuthenticatedClient: raise_on_unexpected_status=True, timeout=httpx.Timeout(self.timeout), ) - else: - self.client.token = token return self.client @staticmethod @@ -184,6 +196,7 @@ def _unwrap_response(self, parsed: Any, success_type: type[T], context: str) -> raise UnexpectedResponseError(f"{context}: API error: {parsed}") def close(self) -> None: + self._discard_client() self.session.close() def __enter__(self) -> "DataCoreAPI": diff --git a/test/test_token_refresh.py b/test/test_token_refresh.py new file mode 100644 index 0000000..0128105 --- /dev/null +++ b/test/test_token_refresh.py @@ -0,0 +1,60 @@ +"""The generated client pins its Authorization header at construction time. + +A token change therefore has to replace the client. These tests exercise that +invariant directly — no HTTP, no transport mocking: the constructor performs no +network I/O, and a pre-set expiry keeps `_ensure_client` from authenticating. +""" + +import time + +from sportradar_datacore_api.api import DataCoreAPI + + +def _api() -> DataCoreAPI: + return DataCoreAPI( + base_url="https://api.example.invalid/v1", + auth_url="https://token.example.invalid/token", + client_id="client-id", + client_secret="client-secret", + sport="handball", + ) + + +def test_token_change_replaces_the_generated_client() -> None: + api = _api() + api._token = "token-1" + api._expires_at = time.time() + 3600 + + first = api._ensure_client() + first_transport = first.get_httpx_client() + assert first_transport.headers["Authorization"] == "Bearer token-1" + + # Exactly what _authenticate() does once a fresh token arrives. + api._token = "token-2" + api._discard_client() + + second = api._ensure_client() + assert second is not first + assert second.get_httpx_client().headers["Authorization"] == "Bearer token-2" + assert first_transport.is_closed, "the superseded transport must be closed" + + +def test_client_survives_when_the_token_is_unchanged() -> None: + api = _api() + api._token = "token-1" + api._expires_at = time.time() + 3600 + + first = api._ensure_client() + assert api._ensure_client() is first + + +def test_close_releases_the_generated_client() -> None: + api = _api() + api._token = "token-1" + api._expires_at = time.time() + 3600 + + transport = api._ensure_client().get_httpx_client() + api.close() + + assert transport.is_closed + assert api.client is None