fix: surface Spotify quota exhaustion
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
358d058392
commit
bfea9efb5e
6 changed files with 97 additions and 10 deletions
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
pool = await self._grounded_pool(
|
try:
|
||||||
session_id, catalog, intent, taste, deadline_at, seeded_pool
|
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:
|
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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue