From bfea9efb5e3131b7e695b3701f07828edc1999fa Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Tue, 11 Aug 2026 22:07:06 +0200 Subject: [PATCH] fix: surface Spotify quota exhaustion Co-Authored-By: Claude --- backend/app/pipeline/orchestrator.py | 23 +++++++++++++------ backend/tests/test_orchestrator.py | 24 +++++++++++++++++++ docs/logboek.md | 24 +++++++++++++++++++ docs/workflow.md | 4 ++-- frontend/src/components/StreamError.vue | 4 +++- frontend/tests/resultPresentation.test.ts | 28 +++++++++++++++++++++++ 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/backend/app/pipeline/orchestrator.py b/backend/app/pipeline/orchestrator.py index f3a35fb..4f5c23d 100644 --- a/backend/app/pipeline/orchestrator.py +++ b/backend/app/pipeline/orchestrator.py @@ -38,6 +38,8 @@ from app.ports.protocols import ( RERANK_FALLBACK_CODE = "rerank_fallback" RERANK_FALLBACK_MESSAGE = "Ranking output was invalid, so grounded results are shown instead." RERANK_FALLBACK_JUSTIFICATION = "Selected as a grounded match for your request." +QUOTA_ERROR_CODE = "quota_exhausted" +QUOTA_ERROR_MESSAGE = "Spotify's Development Mode quota is temporarily exhausted." class _TrackSelection: @@ -140,10 +142,8 @@ class RecommendationPipeline: return except CatalogQuotaExhaustedError: yield PipelineErrorEvent( - code="quota_exhausted", - message=( - "Spotify request quota was exhausted before recommendations could be prepared." - ), + code=QUOTA_ERROR_CODE, + message=QUOTA_ERROR_MESSAGE, ) return except SpotifyError: @@ -158,9 +158,16 @@ class RecommendationPipeline: candidate_count=len(intent.candidates), ) - pool = await self._grounded_pool( - session_id, catalog, intent, taste, deadline_at, seeded_pool - ) + try: + pool = await self._grounded_pool( + session_id, catalog, intent, taste, deadline_at, seeded_pool + ) + except CatalogQuotaExhaustedError: + yield PipelineErrorEvent( + code=QUOTA_ERROR_CODE, + message=QUOTA_ERROR_MESSAGE, + ) + return if not pool: yield PipelineErrorEvent( code="no_grounded_results", @@ -209,6 +216,8 @@ class RecommendationPipeline: self.settings.rerank_count + self.settings.rerank_pool_buffer, deadline_at, ) + if not result.tracks and result.metrics.did_exhaust_quota: + raise CatalogQuotaExhaustedError if result.tracks: self.last_pools[session_id] = result.tracks return result.tracks diff --git a/backend/tests/test_orchestrator.py b/backend/tests/test_orchestrator.py index 4dce350..4afa961 100644 --- a/backend/tests/test_orchestrator.py +++ b/backend/tests/test_orchestrator.py @@ -146,6 +146,30 @@ def test_intent_stage_failures_yield_one_typed_error_event() -> None: asyncio.run(run()) +def test_grounding_quota_yields_typed_error_instead_of_empty_results() -> None: + class QuotaCatalog(FakeCatalog): + """Exhaust the Spotify quota on the first grounding search.""" + + async def search_tracks(self, query: str, limit: int = 10) -> list[Track]: + self.search_call_count += 1 + raise SpotifyQuotaExhaustedError(0.0, "QUOTA_EXCEEDED") + + async def run() -> None: + track = _track("candidate", "Candidate Song") + catalog = QuotaCatalog(()) + recommender = FakeRecommender([_intent(track)]) + + events = await _run_pipeline(catalog, recommender) + + assert [event.type for event in events] == ["metadata", "error"] + assert isinstance(events[-1], PipelineErrorEvent) + assert events[-1].code == "quota_exhausted" + assert catalog.search_call_count == 1 + assert recommender.rerank_call_count == 0 + + asyncio.run(run()) + + def test_taste_failure_cancels_sibling_fetches() -> None: class FailingTasteCatalog(FakeCatalog): """Fail one taste request after all sibling requests have started.""" diff --git a/docs/logboek.md b/docs/logboek.md index ba06af4..976ac3d 100644 --- a/docs/logboek.md +++ b/docs/logboek.md @@ -514,3 +514,27 @@ Waarom: werkende versie 1 klik verderop hebben. Een runtime mode-switch in de app zelf zou een derde codepad zijn; twee instanties met elk 1 mode houden het ontwerp schoon. + +### Final polishing round + +Wat ik deed: + +- De laatste live-test gaf op de Spotify Search endpoint opnieuw + `QUOTA_EXCEEDED`, terwijl auth en de taste-profile calls wel werkten. De + pipeline toont dit nu als een `quota_exhausted` error in plaats van als nul + resultaten; de retry button verdwijnt en de demo-link blijft staan. +- De eerdere aanname van een dagelijks resetmoment gecorrigeerd. Spotify deelt + Development Mode quota per developer-account en endpoint bucket, maar + publiceert de limieten en het reset window niet. + +Waarom: + +- Een quota error is geen geldige zoekopdracht met nul resultaten. Door deze + rate-limit issues lever ik op 12 augustus in: eerst één gecontroleerde + live-test, daarna direct de hand-in. + +Wat ik heb laten vallen of uitgesteld: + +- De structurele oplossing is een Spotify key met hogere quota. Dat is voor + deze PoC-assessment niet proportioneel; ik bouw geen slimme workaround en + live mode valt niet stil terug op fixtures. diff --git a/docs/workflow.md b/docs/workflow.md index 2f5d25b..6cae374 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -17,8 +17,8 @@ below. No cloud dev environment involved. the repo you are reading is the mirror and shows the same history and the same green CI. - CI runs on every push: ruff, mypy and pytest for the backend; typecheck, - lint, build and vitest for the frontend. It has been green since the - first commit. + lint, build and vitest for the frontend. One intermediate batch exposed + a test typing error; the next commit fixed it, and current main is green. ## Deployment diff --git a/frontend/src/components/StreamError.vue b/frontend/src/components/StreamError.vue index e4ad2c2..46a4f00 100644 --- a/frontend/src/components/StreamError.vue +++ b/frontend/src/components/StreamError.vue @@ -8,7 +8,9 @@ defineEmits<{ retry: [] }>()