fix: rate-limit MB url-rels fetches, serialize frontend image loads
Artist image resolution was hitting musicbrainz.org with 10 concurrent unthrottled requests per search — enough to trigger MB's rate limit rejection. Two fixes: Backend: add dedicated 1 req/s RateLimiter for MB url-rels fetches in ArtistImageProvider. Each fetch waits on the limiter before the HTTP call. Results are cached 30 days so repeat lookups are instant. Frontend: switch loadArtistImages from concurrent fire-all to sequential await loop. Each artist image loads one at a time, images appear progressively as they resolve instead of all failing from rate limit rejection.
This commit is contained in:
@@ -40,21 +40,24 @@ const (
|
|||||||
// URL. Designed to be extended with additional sources (fanart.tv,
|
// URL. Designed to be extended with additional sources (fanart.tv,
|
||||||
// etc.) by adding to the resolve chain.
|
// etc.) by adding to the resolve chain.
|
||||||
type ArtistImageProvider struct {
|
type ArtistImageProvider struct {
|
||||||
cache *Cache
|
cache *Cache
|
||||||
client *http.Client
|
mbLimiter *RateLimiter
|
||||||
logger *slog.Logger
|
client *http.Client
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArtistImageProvider creates a provider that resolves artist
|
// NewArtistImageProvider creates a provider that resolves artist
|
||||||
// images via MusicBrainz relationships and Wikidata.
|
// images via MusicBrainz relationships and Wikidata.
|
||||||
func NewArtistImageProvider(
|
func NewArtistImageProvider(
|
||||||
cache *Cache,
|
cache *Cache,
|
||||||
|
mbLimiter *RateLimiter,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) *ArtistImageProvider {
|
) *ArtistImageProvider {
|
||||||
return &ArtistImageProvider{
|
return &ArtistImageProvider{
|
||||||
cache: cache,
|
cache: cache,
|
||||||
client: &http.Client{Timeout: artistImageTimeout},
|
mbLimiter: mbLimiter,
|
||||||
logger: logger,
|
client: &http.Client{Timeout: artistImageTimeout},
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +133,11 @@ func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation {
|
|||||||
artistMBID,
|
artistMBID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Rate-limit the MB API call.
|
||||||
|
if err := p.mbLimiter.Wait(context.Background()); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
body, err := p.fetchURL(url)
|
body, err := p.fetchURL(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Debug("artist image: MB rels fetch failed",
|
p.logger.Debug("artist image: MB rels fetch failed",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
|
|||||||
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
|
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
|
||||||
index := NewSearchIndex(db, lb, logger.WithGroup("search-index"))
|
index := NewSearchIndex(db, lb, logger.WithGroup("search-index"))
|
||||||
artProxy := NewCoverArtProxy(db, limiter)
|
artProxy := NewCoverArtProxy(db, limiter)
|
||||||
artistImg := NewArtistImageProvider(cache, logger.WithGroup("artist-image"))
|
artistImg := NewArtistImageProvider(cache, NewRateLimiter(), logger.WithGroup("artist-image"))
|
||||||
|
|
||||||
logger.Info("explore service created")
|
logger.Info("explore service created")
|
||||||
|
|
||||||
|
|||||||
@@ -666,25 +666,25 @@ export class ExploreView extends LitElement {
|
|||||||
* Load artist images for all visible artist cards. Each call
|
* Load artist images for all visible artist cards. Each call
|
||||||
* is async and updates the cache + re-renders on success.
|
* is async and updates the cache + re-renders on success.
|
||||||
*/
|
*/
|
||||||
private loadArtistImages() {
|
private async loadArtistImages() {
|
||||||
if (!this.results?.artists?.length) return;
|
if (!this.results?.artists?.length) return;
|
||||||
|
|
||||||
|
// Load sequentially to avoid hammering the MB rate limiter.
|
||||||
for (const a of this.results.artists) {
|
for (const a of this.results.artists) {
|
||||||
if (this.artistImageCache.has(a.mbid)) continue;
|
if (this.artistImageCache.has(a.mbid)) continue;
|
||||||
|
|
||||||
// Mark as loading.
|
|
||||||
this.artistImageCache.set(a.mbid, '');
|
this.artistImageCache.set(a.mbid, '');
|
||||||
|
|
||||||
GetArtistImageURL(a.mbid)
|
try {
|
||||||
.then((url) => {
|
const url = await GetArtistImageURL(a.mbid);
|
||||||
if (url) {
|
|
||||||
this.artistImageCache.set(a.mbid, url);
|
if (url) {
|
||||||
this.requestUpdate();
|
this.artistImageCache.set(a.mbid, url);
|
||||||
}
|
this.requestUpdate();
|
||||||
})
|
}
|
||||||
.catch(() => {
|
} catch {
|
||||||
// No image — leave empty string.
|
// No image — leave empty string.
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user