fix(worker): retry transient MusicBrainz failures with backoff

The matching stage makes up to three musicbrainzngs calls per job, none of
which retried. A dropped TLS connection (surfacing as NetworkError,
"<urlopen error [SSL: UNEXPECTED_EOF_WHILE_READING]>") or a 503 rate-limit
(ResponseError) failed the entire pipeline for the job, which then re-queued
and re-ran from scratch — ~36% of jobs churned this way over a 6h window.

Wrap the three MB calls in _with_retry (4 attempts, 1s/2s/4s exponential
backoff), catching only the two transient musicbrainzngs error types so real
bugs still propagate. musicbrainzngs stays lazily imported to keep module
import offline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-20 16:45:37 +02:00
parent 675d964bbe
commit ca5bbfa9da
2 changed files with 102 additions and 5 deletions
+28 -4
View File
@@ -1,3 +1,4 @@
import time
from difflib import SequenceMatcher
from lyra_worker.types import MBTarget
@@ -5,6 +6,29 @@ from lyra_worker.types import MBTarget
_MIN_TITLE_RATIO = 0.6
def _with_retry(fn, *, attempts: int = 4, base_delay: float = 1.0, sleep=time.sleep):
"""Call `fn`, retrying transient MusicBrainz failures with exponential backoff.
A dropped TLS connection to the MusicBrainz webservice surfaces as
musicbrainzngs.NetworkError (`<urlopen error [SSL: UNEXPECTED_EOF_WHILE_READING]>`),
and MusicBrainz's own 503 rate-limit surfaces as ResponseError; both are
transient, so we retry rather than failing the whole pipeline for the job.
Any other exception (bug, non-transient) propagates immediately.
musicbrainzngs is imported lazily so importing this module stays offline.
"""
import musicbrainzngs
transient = (musicbrainzngs.NetworkError, musicbrainzngs.ResponseError)
for i in range(attempts):
try:
return fn()
except transient:
if i == attempts - 1:
raise
sleep(base_delay * (2**i))
def _ratio(a: str, b: str) -> float:
return SequenceMatcher(None, a.strip().casefold(), b.strip().casefold()).ratio()
@@ -68,7 +92,7 @@ class MusicBrainzResolver:
musicbrainzngs.set_useragent(self._app, self._version, self._contact)
res = musicbrainzngs.search_release_groups(query=album, artist=artist, limit=5)
res = _with_retry(lambda: musicbrainzngs.search_release_groups(query=album, artist=artist, limit=5))
groups = res.get("release-group-list", [])
rg = _best_release_group(artist, album, groups)
if rg is None:
@@ -93,11 +117,11 @@ class MusicBrainzResolver:
titles: tuple[str, ...] = ()
rgid = rg.get("id")
if rgid:
rgfull = musicbrainzngs.get_release_group_by_id(rgid, includes=["releases"])
rgfull = _with_retry(lambda: musicbrainzngs.get_release_group_by_id(rgid, includes=["releases"]))
releases = rgfull.get("release-group", {}).get("release-list", [])
if releases:
rel = musicbrainzngs.get_release_by_id(
releases[0]["id"], includes=["recordings"]
rel = _with_retry(
lambda: musicbrainzngs.get_release_by_id(releases[0]["id"], includes=["recordings"])
).get("release", {})
tracks = []
for medium in rel.get("medium-list", []):