Persist distinct recoverable Spotify ingest failure states

SI
5d ago
+448-18

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:

  • callContextRpc wraps database exceptions as Context 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).
  • fetchSpotifyContext now rejects a malformed track ID as unsupported_input instead of a ZodError (which would have read as an invalid provider response); a non-JSON provider body (SyntaxError) classifies as provider_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 (existing toThrow("429") and "different recording" assertions still pass).
  • Ran: pnpm exec vitest run lib/context → 68 files passed, 8 skipped (*.live.test.ts / *.local.test.ts without credentials); 368 tests passed, 8 skipped, 0 failed.
  • Ran: pnpm exec tsc --noEmit → 0 errors in lib/context/** and app/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 eslint on the 8 changed TypeScript files → 0 problems; pnpm exec prettier --check on those plus README.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, ISRC USABC2600001) plus AAAAAAAAAAAAAAAAAAAAAA; no real customer identifiers.
  • Red → green: ingestFailureStates.test.ts first failed with Cannot 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 naming ingest_release, HTTP 404/410/429/500/503/401/403 and the relinked recording threw plain Error, and runContextRequest persisted the fixed string instead of recording_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: contextOperationHandler still maps every thrown operation error to one 409 string (#2125 territory).
  • Guest fail path: fail_context_guest stores 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 yet guard in runContextRequest is left as a plain Error (classifies as unknown) to avoid overlapping #2131.
  • Network-level fetch TypeErrors classify as unknown, not provider_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 ContextIngestFailure carries one of seven codes (unsupported_input, recording_unavailable, provider_outage, provider_rejected, provider_response_invalid, identity_conflict, unknown), classified from any thrown value including ZodError, timeouts, and the commit_spotify_context exception texts.
  • runContextRequest persists <code>: <recovery guidance> via fail_context_request, bounded and never echoing a raw provider body.
  • parseContextUrl routes album URLs to ingest_release and names playlists and artist pages explicitly; contextIngestSchema surfaces that message in the HTTP 400 issues[].
  • 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 callContextRpc wraps them with a Context storage operation failed: prefix.
  • A malformed track ID now classifies as unsupported_input; a non-JSON provider body classifies as provider_response_invalid.
  • No database migration and no app change; the error column and failed-request re-claim already exist.