From b5cabc1eb20604b5afd5657638117bc7d20af6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Skyler=20M=C3=A4ntysaari?= Date: Sun, 16 Aug 2026 12:58:11 +0300 Subject: [PATCH] Fix Spotify's Feb 2026 playlist-endpoint migration; simplify rate-limit retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spotify moved playlist creation from POST /users/{id}/playlists to POST /me/playlists, and playlist track read/write from /playlists/{id}/tracks to /playlists/{id}/items (with the per-row field renamed track -> item) — same Feb 2026 migration that already hit /me/tracks. Both now 403 unconditionally on the old paths. Also removes a redundant second retry layer: every adapter call already retries on 429 via fetchWithRetry, so the extra withRetry wrapper around write calls in the job runner could compound into ~25 total attempts with stacked delays before failing — with zero progress events firing the whole time, which looked exactly like a frozen UI. fetchWithRetry now also caps how long it will ever sleep for (10s): a Retry-After header can legitimately ask for hours (a real quota ban, not a transient blip), and blindly honoring that would hang a job for the rest of the day instead of failing fast. Co-Authored-By: Claude Sonnet 5 --- lib/adapters/http-retry.ts | 32 ++++++++++++++++++++++++++------ lib/adapters/spotify.ts | 25 +++++++++++++------------ lib/adapters/tidal.ts | 4 ---- lib/adapters/types.ts | 2 -- lib/jobs/rate-limit.ts | 33 --------------------------------- 5 files changed, 39 insertions(+), 57 deletions(-) delete mode 100644 lib/jobs/rate-limit.ts diff --git a/lib/adapters/http-retry.ts b/lib/adapters/http-retry.ts index 0d1eda4..cc92ec2 100644 --- a/lib/adapters/http-retry.ts +++ b/lib/adapters/http-retry.ts @@ -1,15 +1,22 @@ const MAX_ATTEMPTS = 5; const FALLBACK_BASE_MS = 500; +// A Retry-After header can legitimately ask for hours (a real quota ban, not a +// transient blip) — automatically sleeping that long would hang the job for +// the caller. Anything past this cap is treated as "not worth waiting out +// automatically" and fails fast instead. +const MAX_RETRY_DELAY_MS = 10_000; + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Runs doFetch, retrying on 429 responses using the Retry-After header - * (falling back to exponential backoff if absent). Applied at the low-level - * fetch helper so every call site (reads and writes alike) is covered - * automatically, rather than requiring each caller to wrap itself. + * (falling back to exponential backoff if absent, and capped at + * MAX_RETRY_DELAY_MS regardless). Applied at the low-level fetch helper so + * every call site (reads and writes alike) is covered automatically, rather + * than requiring each caller to wrap itself. */ export async function fetchWithRetry(doFetch: () => Promise): Promise { let attempt = 0; @@ -19,10 +26,23 @@ export async function fetchWithRetry(doFetch: () => Promise): Promise< if (res.status !== 429) return res; attempt++; - if (attempt >= MAX_ATTEMPTS) return res; const retryAfterHeader = res.headers.get("Retry-After"); - const delay = retryAfterHeader ? Number(retryAfterHeader) * 1000 : FALLBACK_BASE_MS * 2 ** (attempt - 1); - await sleep(delay); + const requestedDelay = retryAfterHeader ? Number(retryAfterHeader) * 1000 : FALLBACK_BASE_MS * 2 ** (attempt - 1); + + if (requestedDelay > MAX_RETRY_DELAY_MS) { + console.warn( + `[rate-limit] 429 on ${res.url} — Retry-After (${requestedDelay}ms) exceeds the ${MAX_RETRY_DELAY_MS}ms cap, giving up rather than hanging` + ); + return res; + } + + if (attempt >= MAX_ATTEMPTS) { + console.warn(`[rate-limit] giving up after ${attempt} attempts on ${res.url}`); + return res; + } + + console.warn(`[rate-limit] 429 on ${res.url} — attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${requestedDelay}ms`); + await sleep(requestedDelay); } } diff --git a/lib/adapters/spotify.ts b/lib/adapters/spotify.ts index 45a5266..f4c6796 100644 --- a/lib/adapters/spotify.ts +++ b/lib/adapters/spotify.ts @@ -14,7 +14,7 @@ import type { const API_BASE = "https://api.spotify.com/v1"; const ADD_TO_LIBRARY_BATCH_SIZE = 40; // PUT /me/library cap -const ADD_TO_PLAYLIST_BATCH_SIZE = 100; // POST /playlists/{id}/tracks cap +const ADD_TO_PLAYLIST_BATCH_SIZE = 100; // POST /playlists/{id}/items cap interface SpotifyArtistRef { id: string; @@ -57,7 +57,7 @@ interface SpotifyPlaylistsPage { } interface SpotifyPlaylistTrackItem { - track: SpotifyTrack | null; + item: SpotifyTrack | null; } interface SpotifyPlaylistTracksPage { @@ -210,12 +210,14 @@ export const spotifyAdapter: MusicPlatformAdapter = { }, async *fetchPlaylistTracks(tokens: TokenSet, playlistId: string): AsyncGenerator { - let path: string | null = `/playlists/${playlistId}/tracks?limit=100`; + // Spotify renamed GET /playlists/{id}/tracks to /playlists/{id}/items + // (Feb 2026 migration), with the per-row field renamed track -> item. + let path: string | null = `/playlists/${playlistId}/items?limit=100`; while (path) { const page: SpotifyPlaylistTracksPage = await spotifyFetch(path, tokens); yield page.items - .filter((item) => item.track != null) - .map((item) => toCanonicalTrack(item.track as SpotifyTrack)); + .filter((entry) => entry.item != null) + .map((entry) => toCanonicalTrack(entry.item as SpotifyTrack)); path = page.next ? page.next.replace(API_BASE, "") : null; } }, @@ -282,8 +284,9 @@ export const spotifyAdapter: MusicPlatformAdapter = { }, async createPlaylist(tokens, name, description): Promise<{ id: string }> { - const me = await spotifyFetch("/me", tokens); - const playlist = await spotifyFetch(`/users/${me.id}/playlists`, tokens, { + // Spotify deprecated POST /users/{user_id}/playlists in favor of + // POST /me/playlists (Feb 2026 migration) — old endpoint now 403s. + const playlist = await spotifyFetch("/me/playlists", tokens, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, description, public: false }), @@ -292,9 +295,11 @@ export const spotifyAdapter: MusicPlatformAdapter = { }, async addTracksToPlaylist(tokens, playlistId, platformTrackIds): Promise { + // Spotify renamed POST /playlists/{id}/tracks to /playlists/{id}/items + // (Feb 2026 migration) — body shape (uris) is unchanged. for (let i = 0; i < platformTrackIds.length; i += ADD_TO_PLAYLIST_BATCH_SIZE) { const batch = platformTrackIds.slice(i, i + ADD_TO_PLAYLIST_BATCH_SIZE); - await spotifyFetch(`/playlists/${playlistId}/tracks`, tokens, { + await spotifyFetch(`/playlists/${playlistId}/items`, tokens, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uris: batch.map((id) => `spotify:track:${id}`) }), @@ -309,8 +314,4 @@ export const spotifyAdapter: MusicPlatformAdapter = { async saveAlbum(tokens, platformAlbumId): Promise { await addToLibrary(tokens, [`spotify:album:${platformAlbumId}`]); }, - - getRetryDelayMs(error: unknown): number | null { - return error instanceof SpotifyRateLimitError ? error.retryAfterMs : null; - }, }; diff --git a/lib/adapters/tidal.ts b/lib/adapters/tidal.ts index c4e785c..00be3ab 100644 --- a/lib/adapters/tidal.ts +++ b/lib/adapters/tidal.ts @@ -332,8 +332,4 @@ export const tidalAdapter: MusicPlatformAdapter = { async saveAlbum(tokens, platformAlbumId): Promise { await addRelationshipItems(tokens, "/userCollectionAlbums/me/relationships/items", "albums", [platformAlbumId]); }, - - getRetryDelayMs(error: unknown): number | null { - return error instanceof TidalRateLimitError ? error.retryAfterMs : null; - }, }; diff --git a/lib/adapters/types.ts b/lib/adapters/types.ts index ad8e9fa..0f4a6b1 100644 --- a/lib/adapters/types.ts +++ b/lib/adapters/types.ts @@ -40,6 +40,4 @@ export interface MusicPlatformAdapter { addTracksToPlaylist(tokens: TokenSet, playlistId: string, platformTrackIds: string[]): Promise; followArtist(tokens: TokenSet, platformArtistId: string): Promise; saveAlbum(tokens: TokenSet, platformAlbumId: string): Promise; - - getRetryDelayMs?(error: unknown): number | null; } diff --git a/lib/jobs/rate-limit.ts b/lib/jobs/rate-limit.ts deleted file mode 100644 index fbb097f..0000000 --- a/lib/jobs/rate-limit.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { MusicPlatformAdapter } from "@/lib/adapters/types"; - -const MAX_ATTEMPTS = 5; -const FALLBACK_BASE_MS = 500; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Runs fn, retrying on rate-limit errors (as classified by adapter.getRetryDelayMs). - * Uses the adapter-reported delay when available, otherwise exponential backoff. - */ -export async function withRetry(fn: () => Promise, adapter: MusicPlatformAdapter): Promise { - let attempt = 0; - - while (true) { - try { - return await fn(); - } catch (err) { - attempt++; - const retryDelayMs = adapter.getRetryDelayMs?.(err) ?? null; - const isRateLimit = retryDelayMs != null; - - if (!isRateLimit || attempt >= MAX_ATTEMPTS) { - throw err; - } - - const delay = retryDelayMs > 0 ? retryDelayMs : FALLBACK_BASE_MS * 2 ** (attempt - 1); - await sleep(delay); - } - } -} -- 2.51.2