fix: surface Spotify quota exhaustion
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Visser 2026-08-11 22:07:06 +02:00
parent 358d058392
commit bfea9efb5e
6 changed files with 97 additions and 10 deletions

View file

@ -38,6 +38,8 @@ from app.ports.protocols import (
RERANK_FALLBACK_CODE = "rerank_fallback" RERANK_FALLBACK_CODE = "rerank_fallback"
RERANK_FALLBACK_MESSAGE = "Ranking output was invalid, so grounded results are shown instead." RERANK_FALLBACK_MESSAGE = "Ranking output was invalid, so grounded results are shown instead."
RERANK_FALLBACK_JUSTIFICATION = "Selected as a grounded match for your request." 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: class _TrackSelection:
@ -140,10 +142,8 @@ class RecommendationPipeline:
return return
except CatalogQuotaExhaustedError: except CatalogQuotaExhaustedError:
yield PipelineErrorEvent( yield PipelineErrorEvent(
code="quota_exhausted", code=QUOTA_ERROR_CODE,
message=( message=QUOTA_ERROR_MESSAGE,
"Spotify request quota was exhausted before recommendations could be prepared."
),
) )
return return
except SpotifyError: except SpotifyError:
@ -158,9 +158,16 @@ class RecommendationPipeline:
candidate_count=len(intent.candidates), candidate_count=len(intent.candidates),
) )
try:
pool = await self._grounded_pool( pool = await self._grounded_pool(
session_id, catalog, intent, taste, deadline_at, seeded_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: if not pool:
yield PipelineErrorEvent( yield PipelineErrorEvent(
code="no_grounded_results", code="no_grounded_results",
@ -209,6 +216,8 @@ class RecommendationPipeline:
self.settings.rerank_count + self.settings.rerank_pool_buffer, self.settings.rerank_count + self.settings.rerank_pool_buffer,
deadline_at, deadline_at,
) )
if not result.tracks and result.metrics.did_exhaust_quota:
raise CatalogQuotaExhaustedError
if result.tracks: if result.tracks:
self.last_pools[session_id] = result.tracks self.last_pools[session_id] = result.tracks
return result.tracks return result.tracks

View file

@ -146,6 +146,30 @@ def test_intent_stage_failures_yield_one_typed_error_event() -> None:
asyncio.run(run()) 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: def test_taste_failure_cancels_sibling_fetches() -> None:
class FailingTasteCatalog(FakeCatalog): class FailingTasteCatalog(FakeCatalog):
"""Fail one taste request after all sibling requests have started.""" """Fail one taste request after all sibling requests have started."""

View file

@ -514,3 +514,27 @@ Waarom:
werkende versie 1 klik verderop hebben. Een runtime mode-switch in de 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 app zelf zou een derde codepad zijn; twee instanties met elk 1 mode
houden het ontwerp schoon. 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.

View file

@ -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 repo you are reading is the mirror and shows the same history and
the same green CI. the same green CI.
- CI runs on every push: ruff, mypy and pytest for the backend; typecheck, - 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 lint, build and vitest for the frontend. One intermediate batch exposed
first commit. a test typing error; the next commit fixed it, and current main is green.
## Deployment ## Deployment

View file

@ -8,7 +8,9 @@ defineEmits<{ retry: [] }>()
<template> <template>
<div class="error" role="alert" :data-error-code="code"> <div class="error" role="alert" :data-error-code="code">
<p>{{ message }}</p> <p>{{ message }}</p>
<button type="button" @click="$emit('retry')">{{ messages.streamErrorRetry }}</button> <button v-if="code !== 'quota_exhausted'" type="button" @click="$emit('retry')">
{{ messages.streamErrorRetry }}
</button>
<a v-if="demoUrl" class="demo-link" :href="demoUrl"> <a v-if="demoUrl" class="demo-link" :href="demoUrl">
{{ messages.streamErrorDemoInstance }} {{ messages.streamErrorDemoInstance }}
</a> </a>

View file

@ -166,6 +166,34 @@ describe('result presentation', () => {
expect(onPick).toHaveBeenCalledWith('Focus') expect(onPick).toHaveBeenCalledWith('Focus')
}) })
it('renders a quota error without retry and keeps the demo escape hatch', () => {
const turn: AssistantTurn = {
...doneTurn(),
status: 'error',
tracks: [],
warnings: [],
error: {
type: 'error',
code: 'quota_exhausted',
message: "Spotify's Development Mode quota is temporarily exhausted.",
},
completion: null,
}
const demoUrl = 'https://demo.example.com'
const root = mountComponent(AssistantMessage, {
turn,
canSave: false,
demoUrl,
})
const alert = root.querySelector<HTMLElement>('[role="alert"]')
const demoLink = alert?.querySelector<HTMLAnchorElement>('a')
expect(alert?.dataset.errorCode).toBe('quota_exhausted')
expect(alert?.querySelector('button')).toBeNull()
expect(demoLink?.href).toBe(`${demoUrl}/`)
expect(demoLink?.textContent).toContain('Open the demo instance')
})
it('renders a first-stage stream error with its code and message', () => { it('renders a first-stage stream error with its code and message', () => {
const turn: AssistantTurn = { const turn: AssistantTurn = {
...doneTurn(), ...doneTurn(),