fix(lastfm): return agents.ErrNotFound on error 6 (not found)

Last.fm returns error 6 for a missing artist/album — a definitive negative —
but the agent returned the raw *lastFMError, so the artwork worker treated
every not-found as a real fault: it counted toward the per-source circuit
breaker (5 in a row opens it, fast-failing all Last.fm calls including valid
ones) and was retried as a transient error instead of settling absent. On a
first scan of a library with many artists Last.fm lacks, this stalled valid
cover lookups and left entities churning in backoff.

Translate error 6 to the shared agents.ErrNotFound at the agent boundary
(callAlbumGetInfo / callArtistGetInfo), matching how the Deezer agent maps its
client's not-found, and log it at Debug instead of Error — which also removes
the not-found log spam.
This commit is contained in:
Deluan 2026-07-24 17:57:53 -04:00
parent 8b844ee102
commit 45509d0155
2 changed files with 23 additions and 5 deletions

View File

@ -291,12 +291,13 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, "", lang)
if err != nil {
var lfErr *lastFMError
if errors.As(err, &lfErr) && lfErr.Code == 6 {
log.Debug(ctx, "Album not found", "album", name, "artist", artist, err)
} else {
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err)
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
// A not-found is a definitive absence, not a fault: return the shared sentinel so the
// artwork worker's breaker/transient checks don't retry it, and log it at Debug.
log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist)
return nil, agents.ErrNotFound
}
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err)
return nil, err
}
return a, nil
@ -308,6 +309,12 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang s
a, err := l.client.artistGetInfo(ctx, name, lang)
if err != nil {
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
// A not-found is a definitive absence, not a fault: return the shared sentinel so it
// doesn't trip the artwork worker's breaker, and log at Debug instead of Error.
log.Debug(ctx, "Artist not found in Last.fm", "artist", name)
return nil, agents.ErrNotFound
}
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
return nil, err
}

View File

@ -576,6 +576,9 @@ var _ = Describe("lastfmAgent", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
// error 6 is a definitive not-found, so it must satisfy the shared sentinel (else it
// would trip the artwork worker's circuit breaker and be retried as a transient fault).
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
// No MBID retry: album.getInfo is queried by name+artist only, in a single call.
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
@ -609,6 +612,14 @@ var _ = Describe("lastfmAgent", func() {
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
})
It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() {
apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "")
// Definitive not-found, not a fault — must satisfy the sentinel through the %w wrap so
// runs of missing artists never trip the artwork worker's circuit breaker.
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
})
It("returns empty list if image is the ignored default image", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}