Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/sportradar_datacore_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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":
Expand Down
60 changes: 60 additions & 0 deletions test/test_token_refresh.py
Original file line number Diff line number Diff line change
@@ -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