Persist distinct recoverable Spotify ingest failure states
Summary
Makes Spotify ingest failures distinct and recoverable, and routes album/playlist/artist URLs explicitly. Before this change fetchSpotifyContext threw one generic Error for every non-OK status, runContextRequest persisted one fixed string for every cause (including database identity conflicts), and contextIngestSchema replaced the parser's message with a static sentence, so a caller polling read could not tell a deleted track from an outage from an identity conflict, and an album URL got no pointer to ingest_release. Now a typed ContextIngestFailure carries a code, classifyContextIngestFailure maps any thrown value (typed failure, ZodError, TimeoutError/AbortError, the commit_spotify_context exception texts) to one of unsupported_input | recording_unavailable | provider_outage | provider_rejected | provider_response_invalid | identity_conflict | unknown, and describeContextIngestFailure persists <code>: <recovery guidance> (bounded, never a raw provider body) through the existing fail_context_request RPC. parseContextUrl names album (→ ingest_release), playlist and artist inputs, and the schema's superRefine surfaces that message in the HTTP 400 issues[]. No artist, recording or release record is written on any failure path (fetch failures never reach commit; commit conflicts abort the transaction). Advances ticket Done-when "Malformed/unavailable URLs and provider errors produce recoverable states without creating incorrect artist records" and scope "Route unsupported album/playlist inputs explicitly".
Refs recoupable/app#2126 (child of epic recoupable/app#2116).
Merge sequencing
docs → database → api → app. Companion docs PR: recoupable/docs "Document Spotify ingest failure states and album routing" (same branch name) merges first. No database migration (the error column and failed-request re-claim already exist) and no app change (ContextFunnel already renders the server error string). Depends on no open PR.
Deviations from the scoping plan, found while validating against the code:
callContextRpcwraps database exceptions asContext storage operation failed: <message>, so the classifier matches the conflict texts by substring rather than prefix (covered by tests for both the bare and wrapped forms).fetchSpotifyContextnow rejects a malformed track ID asunsupported_inputinstead of aZodError(which would have read as an invalid provider response); a non-JSON provider body (SyntaxError) classifies asprovider_response_invalid.
Files: new lib/context/ContextIngestFailure.ts, classifyContextIngestFailure.ts, describeContextIngestFailure.ts, __tests__/ingestFailureStates.test.ts; localized edits to fetchSpotifyContext.ts (non-OK branch, ID pre-check, identity mismatch), runContextRequest.ts (catch block p_error only), parseContextUrl.ts (open.spotify.com branch only; YouTube untouched), schema.ts (url field only), README.md (one paragraph). processContextOperation.ts is not edited.
Verification
- Ran:
pnpm install --frozen-lockfile --prefer-offline→ ok. - Ran:
pnpm exec vitest run lib/context/__tests__/ingestFailureStates.test.ts→ 42 passed, 0 failed. - Ran:
pnpm exec vitest run lib/context/__tests__/ingestFailureStates.test.ts lib/context/__tests__/spotifyPipeline.test.ts lib/context/__tests__/foundation.test.ts lib/context/__tests__/operations.test.ts lib/context/__tests__/guest.test.ts lib/context/__tests__/transports.test.ts→ 6 files, 100 passed, 0 failed (existingtoThrow("429")and "different recording" assertions still pass). - Ran:
pnpm exec vitest run lib/context→ 68 files passed, 8 skipped (*.live.test.ts/*.local.test.tswithout credentials); 368 tests passed, 8 skipped, 0 failed. - Ran:
pnpm exec tsc --noEmit→ 0 errors inlib/context/**andapp/api/context/**. The repo-wide run reports pre-existing errors in unrelated test files on origin/main (lib/tasks,lib/trigger,lib/credits,lib/mcp,lib/chats, …), none in files touched here. - Ran:
pnpm exec eslinton the 8 changed TypeScript files → 0 problems;pnpm exec prettier --checkon those plusREADME.md→ clean. - Ran:
git diff --stat origin/main -- lib/context→ only the 9 files listed above. - Fixture identifiers: only the existing synthetic IDs from
spotifyPipeline.test.ts(2ay96C6SLNv9urvXKD3ecB,1234567890123456789012,abcdefghijklmnopqrstuv, ISRCUSABC2600001) plusAAAAAAAAAAAAAAAAAAAAAA; no real customer identifiers. - Red → green:
ingestFailureStates.test.tsfirst failed withCannot find module '../ContextIngestFailure'; after adding the three new modules only, 19 failed / 23 passed (album/playlist/artist URLs threw the generic "Context ingestion currently supports…" message, the schema issue said "Use a Spotify track or a single YouTube video URL" instead of namingingest_release, HTTP 404/410/429/500/503/401/403 and the relinked recording threw plainError, andrunContextRequestpersisted the fixed string instead ofrecording_unavailable:/provider_outage:/identity_conflict:/provider_response_invalid:/unknown:). After the localized edits: 42/42. - Not verified: hosted Vercel deployment, live Spotify requests (no provider call was made; all fixtures are mocked), production or any hosted Supabase database, MCP transport end to end.
Out of scope / known gaps
- HTTP handler status differentiation:
contextOperationHandlerstill maps every thrown operation error to one 409 string (#2125 territory). - Guest fail path:
fail_context_gueststores no error text. - Retaining the originally submitted locator (
intl-xx,?si) alongside the canonical URL (would change the idempotency fingerprint; contract decision under #2118). - The
YouTube context ingestion is not enabled yetguard inrunContextRequestis left as a plainError(classifies asunknown) to avoid overlapping #2131. - Network-level
fetchTypeErrors classify asunknown, notprovider_outage; only HTTP 429/5xx and timeouts are treated as outages. - No YouTube, paid-module, database or chat change.
🤖 Generated with Claude Code
Summary by cubic
Makes Spotify ingest failures distinct and recoverable so a caller polling a failed request can tell a deleted track from a provider outage from an identity conflict, and gives album, playlist, and artist URLs explicit routing instead of one generic rejection.
- A typed
ContextIngestFailurecarries one of seven codes (unsupported_input,recording_unavailable,provider_outage,provider_rejected,provider_response_invalid,identity_conflict,unknown), classified from any thrown value includingZodError, timeouts, and thecommit_spotify_contextexception texts. runContextRequestpersists<code>: <recovery guidance>viafail_context_request, bounded and never echoing a raw provider body.parseContextUrlroutes album URLs toingest_releaseand names playlists and artist pages explicitly;contextIngestSchemasurfaces that message in the HTTP 400issues[].- No artist, recording, or release record is created on any failure path; retrying with the same idempotency key re-claims the failed request.
Notes for review
- The classifier matches commit exception texts by substring because
callContextRpcwraps them with aContext storage operation failed:prefix. - A malformed track ID now classifies as
unsupported_input; a non-JSON provider body classifies asprovider_response_invalid. - No database migration and no app change; the
errorcolumn and failed-request re-claim already exist.