errno

MusicBrainz ws/2 returns 503 "currently busy", and the track lengths you need are often missing

· in APIs and automation · tested on MusicBrainz web service ws/2, Python requests, iTunes Search API

Symptom

I was matching a local album against an online release to recover a correct tracklist — order, titles and durations — so a media library could be tagged from something better than filenames. The first half of that job is a lookup against the MusicBrainz web service, and it kept dying:

HTTP/1.1 503 Service Unavailable

The body is a short page saying the service is currently busy and to try again later. Nothing else: no 429, no Retry-After header worth trusting, no hint about which part of the query was expensive. The failure is not correlated with how long the script has been running either — it hits the very first request of a batch as happily as the tenth.

What it is not

The first instinct is “I am being rate limited, back off harder”. That diagnosis is mostly wrong, and acting on it costs you an afternoon of tuning sleeps that change nothing.

Rate limiting on ws/2 does exist — anonymous clients are expected to stay around one request per second — but when you exceed it you get a response that says so, typically a 503 accompanied by a message about the rate limit, or a 429. The plain “currently busy” page is the shared infrastructure telling you it could not serve this particular request right now. Raising your inter-request delay from one second to five does not make it go away, and you can confirm that in a minute: fire a single request, by hand, with nothing else running.

curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'User-Agent: my-tagger/1.0 ( [email protected] )' \
  'https://musicbrainz.org/ws/2/release/?query=artist:portishead%20AND%20release:dummy&fmt=json'

Run that a handful of times with several seconds between runs. If a cold, isolated, well-spaced request also returns 503, your client’s pacing is not the variable.

The other thing it is not — but easily could become — is a block. There is a rule here that is easy to miss because nothing enforces it loudly until it does: ws/2 requires a descriptive User-Agent that identifies your application and gives a contact, for example my-tagger/1.0 ( [email protected] ). Sending the default UA of your HTTP client is against the usage policy and is a good way to get blocked outright, which will not look like a “busy” page at all. Set the header once on the session, not per call, so that no code path can forget it.

The fix: expect the 503

Treat 503 as an expected response code, not an exception. In practice two to four attempts with roughly three seconds between them clear it. Keep the total budget small, so that a batch job degrades — skips one album and says so — instead of hanging on a service that is having a bad minute.

import time
import requests

MB = "https://musicbrainz.org/ws/2"

session = requests.Session()
session.headers["User-Agent"] = "my-tagger/1.0 ( [email protected] )"


def mb_get(path, params, attempts=3, pause=3.0):
    params = {**params, "fmt": "json"}
    for attempt in range(1, attempts + 1):
        r = session.get(f"{MB}/{path}", params=params, timeout=15)
        if r.status_code == 503 and attempt < attempts:
            time.sleep(pause)
            continue
        r.raise_for_status()
        return r.json()
    return None

That is the whole retry story. You do not need a urllib3 Retry adapter for this; a loop you can read is easier to bound and easier to log. Add a time.sleep(1) between releases in the outer loop to respect the one-request-per-second guidance — in a job that is already I/O bound it costs nothing.

The second problem: the data may not be there

Getting a 200 is where the interesting part starts. A search like

/ws/2/release/?query=artist:<artist> AND release:<title>&fmt=json

returns many candidate releases for any album that sold well: original pressing, reissues, regional variants, compilations that share the title. Artist and title alone do not identify the disc in your hand.

The discriminator that actually works is track count. You know how many files you have; filter on that first, and only then fall back to release date and country:

curl -s -H 'User-Agent: my-tagger/1.0 ( [email protected] )' \
  'https://musicbrainz.org/ws/2/release/?query=artist:portishead%20AND%20release:dummy&fmt=json' \
| jq '.releases[] | {id, title, date, country, tracks: .["track-count"]}'

The harder surprise is that MusicBrainz release data frequently has no track durations at all. The length fields are simply absent for many releases — not zero, not null-with-a-note, just missing. Any matching or tagging step that assumes a length is available will either crash or, worse, quietly compare None against a real number and decide the album does not match.

Fix 2: a second source for the lengths

Use MusicBrainz for identity and canonical titles, and something else for durations. The iTunes lookup endpoint returns a tracklist with trackTimeMillis per track, which is enough to align an album whose MusicBrainz entry has no lengths:

https://itunes.apple.com/lookup?id=<collection-id>&entity=song
def itunes_durations(collection_id):
    r = requests.get(
        "https://itunes.apple.com/lookup",
        params={"id": collection_id, "entity": "song"},
        timeout=15,
    )
    r.raise_for_status()
    return {
        item["trackNumber"]: item["trackTimeMillis"] / 1000.0
        for item in r.json()["results"]
        if item.get("wrapperType") == "track"
    }

Reconcile the two by track number and normalised title, and log the pairs that do not line up instead of guessing. A gap you can see is worth more than a number you invented.

Three practical notes from doing this on a real library:

What to remember

Public metadata APIs are best-effort infrastructure, not a database you own. Three distinct things will go wrong and each needs its own handling: transient 503s that a short bounded retry absorbs; identity ambiguity, where the only reliable discriminator is a property of your own copy such as the track count; and fields that are documented but empty, which no amount of retrying will fill.

A pipeline that assumes any single source is complete does not fail loudly — it silently produces wrong tags. Two sources plus an explicit reconciliation step produce either a correct answer or an honest gap, and the gap is the part that saves you. That, and the User-Agent: it is one line, it is required, and it is the difference between being a polite client and being blocked.

musicbrainz api python metadata