fix: harden request handling, pipeline containment, and startup boundaries
This commit is contained in:
parent
2cc33a721c
commit
3555256a02
18 changed files with 656 additions and 107 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue