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

@ -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]: