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
|
|
@ -9,10 +9,16 @@ import pytest
|
|||
|
||||
from app.adapters.spotify.auth import TokenSet
|
||||
from app.adapters.spotify.client import SpotifyClient
|
||||
from app.adapters.spotify.errors import SpotifyRateLimitedError, SpotifyRequestError
|
||||
from app.adapters.spotify.errors import (
|
||||
SpotifyQuotaExhaustedError,
|
||||
SpotifyRateLimitedError,
|
||||
SpotifyRequestError,
|
||||
SpotifyUnavailableError,
|
||||
)
|
||||
from app.adapters.spotify.session import SpotifySession
|
||||
from app.config import Settings
|
||||
from app.domain.models import Track
|
||||
from app.ports.protocols import CatalogQuotaExhaustedError
|
||||
|
||||
TransportHandler = Callable[[httpx2.Request], Coroutine[None, None, httpx2.Response]]
|
||||
|
||||
|
|
@ -44,19 +50,19 @@ def test_unauthorized_response_refreshes_once_and_returns_result() -> None:
|
|||
def test_concurrent_unauthorized_responses_share_one_refresh() -> None:
|
||||
async def run() -> None:
|
||||
token_calls = 0
|
||||
old_api_calls = 0
|
||||
both_old_requests_arrived = asyncio.Event()
|
||||
api_calls = 0
|
||||
both_initial_requests_arrived = asyncio.Event()
|
||||
|
||||
async def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
nonlocal old_api_calls, token_calls
|
||||
nonlocal api_calls, token_calls
|
||||
if request.url.host == "accounts.spotify.com":
|
||||
token_calls += 1
|
||||
return _token_response()
|
||||
if request.headers["Authorization"] == "Bearer old-access":
|
||||
old_api_calls += 1
|
||||
if old_api_calls == 2:
|
||||
both_old_requests_arrived.set()
|
||||
await asyncio.wait_for(both_old_requests_arrived.wait(), timeout=1)
|
||||
return _token_response("old-access")
|
||||
api_calls += 1
|
||||
if api_calls <= 2:
|
||||
if api_calls == 2:
|
||||
both_initial_requests_arrived.set()
|
||||
await asyncio.wait_for(both_initial_requests_arrived.wait(), timeout=1)
|
||||
return httpx2.Response(401)
|
||||
return httpx2.Response(200, json=_search_payload())
|
||||
|
||||
|
|
@ -69,6 +75,7 @@ def test_concurrent_unauthorized_responses_share_one_refresh() -> None:
|
|||
|
||||
assert first == second
|
||||
assert token_calls == 1
|
||||
assert client.session.refresh_generation == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
|
@ -105,6 +112,7 @@ def test_get_rate_limit_above_cap_raises_without_retry() -> None:
|
|||
await _search_with_handler(handler)
|
||||
|
||||
assert error.value.retry_after_seconds == 6
|
||||
assert not isinstance(error.value, CatalogQuotaExhaustedError)
|
||||
assert api_calls == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
|
@ -136,10 +144,11 @@ def test_quota_exhaustion_raises_without_retry_or_sleep(
|
|||
)
|
||||
|
||||
monkeypatch.setattr("app.adapters.spotify.client.asyncio.sleep", fake_sleep)
|
||||
with pytest.raises(SpotifyRateLimitedError) as error:
|
||||
with pytest.raises(SpotifyQuotaExhaustedError) as error:
|
||||
await _search_with_handler(handler)
|
||||
|
||||
assert error.value.reason == "QUOTA_EXCEEDED"
|
||||
assert isinstance(error.value, CatalogQuotaExhaustedError)
|
||||
assert api_calls == 1
|
||||
assert sleep_calls == 0
|
||||
|
||||
|
|
@ -172,6 +181,46 @@ def test_authentication_then_rate_limit_retries_each_policy_once() -> None:
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_rate_limit_retry_landing_on_unauthorized_refreshes_once() -> None:
|
||||
async def run() -> None:
|
||||
token_calls = 0
|
||||
api_calls = 0
|
||||
|
||||
async def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
nonlocal api_calls, token_calls
|
||||
if request.url.host == "accounts.spotify.com":
|
||||
token_calls += 1
|
||||
return _token_response()
|
||||
api_calls += 1
|
||||
if api_calls == 1:
|
||||
return httpx2.Response(429, headers={"Retry-After": "0"})
|
||||
if api_calls == 2:
|
||||
return httpx2.Response(401)
|
||||
return httpx2.Response(200, json=_search_payload())
|
||||
|
||||
tracks = await _search_with_handler(handler)
|
||||
|
||||
assert len(tracks) == 1
|
||||
assert token_calls == 1
|
||||
assert api_calls == 3
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_transport_error_becomes_spotify_unavailable() -> None:
|
||||
async def run() -> None:
|
||||
async def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
raise httpx2.ConnectError("connection failed", request=request)
|
||||
|
||||
with pytest.raises(SpotifyUnavailableError) as error:
|
||||
await _search_with_handler(handler)
|
||||
|
||||
assert error.value.status_code == 504
|
||||
assert str(error.value) == "Spotify request failed"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_request_error_includes_parsed_spotify_message() -> None:
|
||||
async def run() -> None:
|
||||
async def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
|
|
@ -267,8 +316,8 @@ def _client(http: httpx2.AsyncClient) -> SpotifyClient:
|
|||
return SpotifyClient(http, session, Settings(spotify_client_id="client"))
|
||||
|
||||
|
||||
def _token_response() -> httpx2.Response:
|
||||
return httpx2.Response(200, json={"access_token": "new-access", "expires_in": 3600})
|
||||
def _token_response(access_token: str = "new-access") -> httpx2.Response:
|
||||
return httpx2.Response(200, json={"access_token": access_token, "expires_in": 3600})
|
||||
|
||||
|
||||
def _search_payload() -> dict[str, object]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue