DEV Community

Christian Anderson
Christian Anderson

Posted on

Your Spotify playlist reader isn't broken. It's reading a boolean.

If you have written anything against the Spotify Web API in the last two years, some of
your code is quietly wrong right now — and the failure mode is not an exception. It is a
function that returns [] and looks fine.

I hit this building two headless tools against the API. Everything below was measured
against the live API, not read in a changelog.

First: there were two events, and everyone conflates them

Almost every "Spotify API is broken" post on the internet blames one date for both. I did
this myself, in a README, and had to correct it before publishing. They are unrelated:

27 November 2024 — removals. /recommendations, /audio-features,
/audio-analysis, /related-artists, and the 30-second preview URLs. This is when the
data died. Every "build a playlist generator with audio features" tutorial dates from
before this and cannot work.

February 2026 — renames and trims. Nothing was deleted for good reasons here; things
moved, and limits tightened. This is when working code started failing without saying so.

If you are debugging a missing energy or tempo field, that is the 2024 event and it is
never coming back. If you are debugging an endpoint that used to work last year, read on.

The rename that costs you an afternoon

The paths moved:

Was Now
GET /playlists/{id}/tracks GET /playlists/{id}/items
GET /users/{id}/playlists GET /me/playlists
library operations /me/library

That part is annoying but loud — you get an error, you fix the URL, you move on.

The part that isn't loud: the row field renamed too.

Inside each row of the response, item["track"] became item["item"]. And here is the
bit that makes it expensive:

"track" did not disappear. It survives as a boolean.

So this code does not crash:

# looks fine, has always worked, now returns nothing
tracks = []
for row in resp["items"]:
    t = row.get("track")
    if not t or not t.get("id"):   # <-- t is now `True`
        continue                    #     True.get() never runs; `not True` is False...
    tracks.append(t["id"])          #     ...so this raises, or your lenient
                                    #     version skips every row silently
Enter fullscreen mode Exit fullscreen mode

Depending on how defensive your reader is, you get one of two outcomes:

  • a strict reader raises AttributeError: 'bool' object has no attribute 'get'
  • a lenient reader — the common case, because everyone wraps this in a try — concludes the playlist is empty

An empty playlist is not an error. Your sync job runs, reports success, and writes
nothing. That is the whole problem with this particular change: it converts a rename into
a silent data loss.

The fix is trivial once you know:

t = row.get("item") or row.get("track")
if not isinstance(t, dict):
    continue
Enter fullscreen mode Exit fullscreen mode

Artist genres are gone if you are in Development Mode

This one has no announcement I could find, and it is absolute:

  • batch GET /artists?ids=... returns 403
  • single GET /artists/{id} returns an artist object with no genres key at all

Note that second one carefully. It is not "genres": []. The field is absent. So a
.get("genres", []) gives you an empty list and you tag every artist unknown without
ever noticing the capability is gone.

If you were tagging or grouping by genre, that is dead for dev-mode apps. In my case the
only tag still derivable from the remaining metadata was release decade, so decade became
the default and genre tagging now raises an explicit error rather than silently labelling
the whole library unknown. Fail loudly on a capability you no longer have. Do not
let it degrade into a plausible-looking result.

The limits also moved, quietly

  • search limit is capped at 10. It was 50. Requests for more do not error — you just get 10, so pagination logic built on 50 silently under-fetches.
  • popularity is stripped from responses.
  • Premium is now required for playback.
  • Development Mode user allowlists dropped from 25 to 5.

That last one is worth dwelling on if you were planning to ship something. Between a
5-user allowlist, Premium being mandatory, and Extended Quota gated behind 250k MAU,
there is no path from "weekend project" to "product" here any more. Self-hosted script is
the realistic ceiling. Better to know that before you build a billing page.

403 means "removed", not "you lack a scope"

Removed endpoints answer 403, not 404. This one is already documented in several
places, so I will keep it short — but the practical rule saves real time:

Before you go re-reading the scopes documentation, check whether the path still
exists. A 403 on the Spotify API is far more often a dead endpoint than a permissions
problem.

The one that actually hurt: the quota window re-arms while you are throttled

This is the finding I would most want to have known in advance.

When you hit the daily quota you get a 429 with a Retry-After. I measured one at
82,510 seconds — roughly 23 hours. Fine. But then I kept making calls, and watched
Retry-After grow back toward 24 hours instead of counting down.

A client that retries in a loop can pin your app in a permanent 429 it never escapes.
Every retry pushes the window out again. The naive exponential-backoff loop you would
normally reach for is exactly the wrong tool.

What works is a circuit breaker that stops calling entirely:

# on 429: record an ABSOLUTE wake-up time and make no further calls until then
blocked_until = time.time() + int(resp.headers.get("Retry-After", 3600))
save(blocked_until)          # persist it — see below
Enter fullscreen mode Exit fullscreen mode

Two traps in that ten-line fix, both of which I shipped wrong the first time:

  1. Persist it, or a restart undoes it. If the process restarts it will happily call Spotify on boot and re-arm the window.
  2. Persist the absolute timestamp, not the remaining seconds. I wrote an absolute epoch on save and read back remaining seconds on load. So a restored value of 3600 was interpreted as an epoch in 1970, the breaker computed a negative remaining time, concluded it was unthrottled, and called Spotify immediately — reproducing the exact bug it existed to prevent. The round trip has to agree with itself.

Also worth saying plainly: what exhausted my quota was bulk harvesting — paginating
120 deep across 40+ queries — not normal use. The architectural answer was to stop calling
the API on the hot path at all. A background thread tops up a local index inside a
token-bucket budget, and the user-facing action reads only from that index. It now serves
from 6,559 locally indexed tracks and makes zero API calls in the interaction path.

If your app calls Spotify in response to a user action, that is the design to move away
from.

The short version

  • Two separate events. 2024 removed the audio data; 2026 moved things and tightened limits.
  • row["track"]row["item"], and track is now a boolean that will convince a tolerant reader your playlist is empty.
  • Artist genres is absent, not empty, for dev-mode apps.
  • Search limit silently caps at 10.
  • 403 usually means the path is gone, not that your scopes are wrong.
  • Never retry a 429 in a loop. The window re-arms. Break the circuit and persist an absolute timestamp.

The two tools this came out of are MIT and on GitHub:
setlisted (headless playlist sequencing —
its test suite is fully offline, because the daily quota is genuinely exhaustible) and
spin-that-dice (the local-index
design described above).

I also wrote the whole thing up properly as a
6-page PDF, with a
2-page cheat sheet of just the renames if
that is all you need. Everything technical above is in the free repos too — the PDF just
saves you assembling it.

Top comments (0)