fix: harden request handling, pipeline containment, and startup boundaries

This commit is contained in:
Justin Visser 2026-08-10 21:50:48 +02:00
parent 2cc33a721c
commit 3555256a02
18 changed files with 656 additions and 107 deletions

View file

@ -7,6 +7,7 @@ import httpx2
from app.adapters.spotify.auth import refresh_access_token
from app.adapters.spotify.errors import (
SpotifyAuthenticationError,
SpotifyQuotaExhaustedError,
SpotifyRateLimitedError,
SpotifyRequestError,
SpotifyUnavailableError,
@ -119,7 +120,7 @@ class SpotifyClient:
params: dict[str, str | int] | None = None,
json: dict[str, object] | None = None,
) -> httpx2.Response:
access_token = await self._access_token()
access_token, refresh_generation = await self._access_token()
response = await self._send(
method,
path,
@ -127,18 +128,27 @@ class SpotifyClient:
params=params,
json=json,
)
response = await self._retry_once_if_unauthorized(
response, refresh_generation = await self._retry_once_if_unauthorized(
response,
method,
path,
access_token,
refresh_generation,
params=params,
json=json,
)
response = await self._retry_once_if_rate_limited(
response, refresh_generation = await self._retry_once_if_rate_limited(
response,
method,
path,
refresh_generation,
params=params,
json=json,
)
response, _ = await self._retry_once_if_unauthorized(
response,
method,
path,
refresh_generation,
params=params,
json=json,
)
@ -154,34 +164,41 @@ class SpotifyClient:
json: dict[str, object] | None,
) -> httpx2.Response:
increment_spotify_calls()
return await self.http.request(
method,
f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",
params=params,
json=json,
headers={"Authorization": f"Bearer {access_token}"},
)
try:
return await self.http.request(
method,
f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",
params=params,
json=json,
headers={"Authorization": f"Bearer {access_token}"},
)
except httpx2.HTTPError as error:
raise SpotifyUnavailableError(504, "Spotify request failed") from error
async def _retry_once_if_unauthorized(
self,
response: httpx2.Response,
method: str,
path: str,
access_token: str,
refresh_generation: int,
*,
params: dict[str, str | int] | None,
json: dict[str, object] | None,
) -> httpx2.Response:
) -> tuple[httpx2.Response, int]:
if response.status_code != 401:
return response
return response, refresh_generation
await self._refresh_if_current(access_token)
return await self._send(
method,
path,
self.session.tokens.access_token,
params=params,
json=json,
await self._refresh_if_current(refresh_generation)
access_token, retry_generation = self._token_snapshot()
return (
await self._send(
method,
path,
access_token,
params=params,
json=json,
),
retry_generation,
)
async def _retry_once_if_rate_limited(
@ -189,12 +206,13 @@ class SpotifyClient:
response: httpx2.Response,
method: str,
path: str,
refresh_generation: int,
*,
params: dict[str, str | int] | None,
json: dict[str, object] | None,
) -> httpx2.Response:
) -> tuple[httpx2.Response, int]:
if response.status_code != 429:
return response
return response, refresh_generation
retry_after_seconds = _parse_retry_after(response)
_, reason = _parse_error_details(response)
@ -204,15 +222,19 @@ class SpotifyClient:
or retry_after_seconds is None
or retry_after_seconds > self.settings.spotify_retry_after_cap_seconds
):
return response
return response, refresh_generation
await asyncio.sleep(retry_after_seconds)
return await self._send(
method,
path,
self.session.tokens.access_token,
params=params,
json=json,
access_token, retry_generation = self._token_snapshot()
return (
await self._send(
method,
path,
access_token,
params=params,
json=json,
),
retry_generation,
)
def _raise_for_error(self, response: httpx2.Response) -> httpx2.Response:
@ -223,26 +245,33 @@ class SpotifyClient:
if response.status_code == 401:
raise SpotifyAuthenticationError("Spotify rejected refreshed authentication")
if response.status_code == 429:
if reason == "QUOTA_EXCEEDED":
raise SpotifyQuotaExhaustedError(_parse_retry_after(response), reason)
raise SpotifyRateLimitedError(_parse_retry_after(response), reason)
if response.status_code >= 500:
raise SpotifyUnavailableError(response.status_code, message)
raise SpotifyRequestError(response.status_code, message)
async def _access_token(self) -> str:
access_token = self.session.tokens.access_token
if self.session.tokens.is_expired:
await self._refresh_if_current(access_token)
return self.session.tokens.access_token
async def _access_token(self) -> tuple[str, int]:
tokens = self.session.tokens
refresh_generation = self.session.refresh_generation
if tokens.is_expired:
await self._refresh_if_current(refresh_generation)
return self._token_snapshot()
async def _refresh_if_current(self, access_token: str) -> None:
async def _refresh_if_current(self, refresh_generation: int) -> None:
async with self.session.refresh_lock:
if self.session.tokens.access_token != access_token:
if self.session.refresh_generation != refresh_generation:
return
self.session.tokens = await refresh_access_token(
self.http,
client_id=self.settings.spotify_client_id,
tokens=self.session.tokens,
)
self.session.refresh_generation += 1
def _token_snapshot(self) -> tuple[str, int]:
return self.session.tokens.access_token, self.session.refresh_generation
def _parse_retry_after(response: httpx2.Response) -> float | None: