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
|
|
@ -3,6 +3,7 @@
|
|||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from app.adapters.spotify.errors import SpotifyQuotaExhaustedError, SpotifyUnavailableError
|
||||
from app.config import Settings
|
||||
from app.domain.models import (
|
||||
ConversationTurn,
|
||||
|
|
@ -13,7 +14,7 @@ from app.domain.models import (
|
|||
Track,
|
||||
TrackCandidate,
|
||||
)
|
||||
from app.pipeline.event import PipelineEvent, PipelineTrackEvent
|
||||
from app.pipeline.event import PipelineErrorEvent, PipelineEvent, PipelineTrackEvent
|
||||
from app.pipeline.orchestrator import RecommendationPipeline
|
||||
from app.ports.protocols import RecommenderOutputError, TimeRange
|
||||
|
||||
|
|
@ -61,11 +62,15 @@ class FakeRecommender:
|
|||
intents: list[Intent],
|
||||
selection_ids: tuple[str, ...] = (),
|
||||
failure_count: int = 0,
|
||||
intent_error: Exception | None = None,
|
||||
intent_delay_seconds: float = 0.0,
|
||||
) -> None:
|
||||
"""Store deterministic outputs for successive calls."""
|
||||
self.intents = intents
|
||||
self.selection_ids = selection_ids
|
||||
self.failure_count = failure_count
|
||||
self.intent_error = intent_error
|
||||
self.intent_delay_seconds = intent_delay_seconds
|
||||
self.rerank_call_count = 0
|
||||
|
||||
async def create_intent(
|
||||
|
|
@ -77,6 +82,9 @@ class FakeRecommender:
|
|||
candidate_count: int,
|
||||
) -> Intent:
|
||||
"""Return the next fixed intent."""
|
||||
await asyncio.sleep(self.intent_delay_seconds)
|
||||
if self.intent_error is not None:
|
||||
raise self.intent_error
|
||||
return self.intents.pop(0)
|
||||
|
||||
async def stream_rerank(
|
||||
|
|
@ -115,6 +123,108 @@ def test_event_order_and_rerank_ids_stay_inside_grounded_pool() -> None:
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_intent_stage_failures_yield_one_typed_error_event() -> None:
|
||||
async def run() -> None:
|
||||
cases = (
|
||||
(RecommenderOutputError("invalid intent"), "intent_failed"),
|
||||
(
|
||||
SpotifyQuotaExhaustedError(0.0, "QUOTA_EXCEEDED"),
|
||||
"quota_exhausted",
|
||||
),
|
||||
(SpotifyUnavailableError(503), "spotify_unavailable"),
|
||||
)
|
||||
for error, expected_code in cases:
|
||||
catalog = FakeCatalog(())
|
||||
recommender = FakeRecommender([], intent_error=error)
|
||||
|
||||
events = await _run_pipeline(catalog, recommender)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "error"
|
||||
assert events[0].code == expected_code
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_taste_failure_cancels_sibling_fetches() -> None:
|
||||
class FailingTasteCatalog(FakeCatalog):
|
||||
"""Fail one taste request after all sibling requests have started."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(())
|
||||
self.started_count = 0
|
||||
self.cancelled_count = 0
|
||||
self.all_started = asyncio.Event()
|
||||
self.never_finishes = asyncio.Event()
|
||||
|
||||
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
|
||||
await self._wait_for_all_fetches()
|
||||
await self._wait_until_cancelled()
|
||||
return []
|
||||
|
||||
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
|
||||
await self._wait_for_all_fetches()
|
||||
await self._wait_until_cancelled()
|
||||
return []
|
||||
|
||||
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
|
||||
await self._wait_for_all_fetches()
|
||||
raise SpotifyUnavailableError(503)
|
||||
|
||||
async def _wait_for_all_fetches(self) -> None:
|
||||
self.started_count += 1
|
||||
if self.started_count == 5:
|
||||
self.all_started.set()
|
||||
await self.all_started.wait()
|
||||
|
||||
async def _wait_until_cancelled(self) -> None:
|
||||
try:
|
||||
await self.never_finishes.wait()
|
||||
except asyncio.CancelledError:
|
||||
self.cancelled_count += 1
|
||||
raise
|
||||
|
||||
async def run() -> None:
|
||||
catalog = FailingTasteCatalog()
|
||||
events = await _run_pipeline(catalog, FakeRecommender([]))
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "error"
|
||||
assert events[0].code == "spotify_unavailable"
|
||||
assert catalog.cancelled_count == 4
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_grounding_deadline_starts_before_intent_generation() -> None:
|
||||
async def run() -> None:
|
||||
track = _track("found", "Found Song")
|
||||
catalog = FakeCatalog((track,))
|
||||
recommender = FakeRecommender(
|
||||
[_intent(track)],
|
||||
intent_delay_seconds=0.02,
|
||||
)
|
||||
pipeline = RecommendationPipeline(
|
||||
recommender,
|
||||
Settings(
|
||||
rerank_count=1,
|
||||
rerank_pool_buffer=0,
|
||||
grounding_floor=1,
|
||||
grounding_concurrency=1,
|
||||
request_deadline_seconds=0.01,
|
||||
),
|
||||
)
|
||||
|
||||
events = await _collect(pipeline, catalog, "query")
|
||||
|
||||
assert [event.type for event in events] == ["metadata", "error"]
|
||||
assert isinstance(events[-1], PipelineErrorEvent)
|
||||
assert events[-1].code == "no_grounded_results"
|
||||
assert catalog.search_call_count == 0
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_rerank_fallback_warns_then_streams_grounded_order() -> None:
|
||||
async def run() -> None:
|
||||
first = _track("first", "First Song")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue