MusicBrainz ws/2 returns 503 "currently busy", and the track lengths you need are often missing
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:
- Normalise titles before comparing: case, punctuation and whitespace,
feat.versusfeaturing, and the bracketed suffixes ((2011 Remaster),(Album Version)) that one source carries and the other does not. - Compare durations with a tolerance of a couple of seconds. Different masterings genuinely differ, and an exact-match rule will reject correct matches.
- Never overwrite an existing tag from a match you did not verify. Write the proposed changes to a report, read the report, then apply. Tagging is easy to do and tedious to undo.
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.