Bandcamp Rate Limiting (#3069)
authorAlexey ALERT Rubashёff <alexey.rubasheff@gmail.com>
Mon, 2 Feb 2026 09:11:40 +0000 (11:11 +0200)
committerGitHub <noreply@github.com>
Mon, 2 Feb 2026 09:11:40 +0000 (10:11 +0100)
feat(bandcamp): add rate limiting and retry logic to all API methods

- Upgrade bandcamp-async-api to 0.0.7
- Add BandcampRateLimitError import and handling throughout
- Configure ThrottlerManager with 50 req/10s rate limit and 10 retry attempts
- Add @throttle_with_retries decorator to: get_artist, get_album, get_track,
  get_album_tracks, get_artist_albums, get_artist_toptracks
- Add throttler.acquire() context manager to async generator methods:
  get_library_artists, get_library_albums
- Add proper BandcampRateLimitError handling converting to
  ResourceTemporarilyUnavailable with backoff_time
- Remove redundant exception handlers from get_library_tracks,
  get_stream_details, get_artist_toptracks (called methods handle exceptions)
- Update tests for new default_retry_after parameter

music_assistant/providers/bandcamp/__init__.py
music_assistant/providers/bandcamp/manifest.json
requirements_all.txt
tests/providers/bandcamp/test_provider.py

index c04718ffe7f9d651126a9c94336303384ca4d689..7bfb22a44d721eb492cbd6750988284711347c1f 100644 (file)
@@ -10,6 +10,7 @@ from bandcamp_async_api import (
     BandcampAPIError,
     BandcampMustBeLoggedInError,
     BandcampNotFoundError,
+    BandcampRateLimitError,
     SearchResultAlbum,
     SearchResultArtist,
     SearchResultTrack,
@@ -17,7 +18,12 @@ from bandcamp_async_api import (
 from bandcamp_async_api.models import CollectionType
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
 from music_assistant_models.enums import ConfigEntryType, MediaType, ProviderFeature, StreamType
-from music_assistant_models.errors import InvalidDataError, LoginFailed, MediaNotFoundError
+from music_assistant_models.errors import (
+    InvalidDataError,
+    LoginFailed,
+    MediaNotFoundError,
+    ResourceTemporarilyUnavailable,
+)
 from music_assistant_models.media_items import Album, Artist, AudioFormat, SearchResults, Track
 from music_assistant_models.provider import ProviderManifest
 from music_assistant_models.streamdetails import StreamDetails
@@ -97,7 +103,12 @@ class BandcampProvider(MusicProvider):
 
     _client: BandcampAPIClient
     _converters: BandcampConverters
-    throttler: ThrottlerManager
+    throttler: ThrottlerManager = ThrottlerManager(
+        rate_limit=50,  # requests per period seconds
+        period=10,
+        initial_backoff=3,  # Bandcamp responds with Retry-After 3
+        retry_attempts=10,
+    )
     top_tracks_limit: int
 
     async def handle_async_init(self) -> None:
@@ -106,11 +117,11 @@ class BandcampProvider(MusicProvider):
         self.top_tracks_limit = cast(
             "int", self.config.get_value(CONF_TOP_TRACKS_LIMIT, DEFAULT_TOP_TRACKS_LIMIT)
         )
-
-        # Initialize the new async API client
-        self._client = BandcampAPIClient(session=self.mass.http_session, identity_token=identity)
-
-        self.throttler = ThrottlerManager(rate_limit=1, period=2)
+        self._client = BandcampAPIClient(
+            session=self.mass.http_session,
+            identity_token=identity,
+            default_retry_after=3,  # Bandcamp responds with Retry-After 3
+        )
         self._converters = BandcampConverters(self.domain, self.instance_id)
 
     @property
@@ -135,6 +146,10 @@ class BandcampProvider(MusicProvider):
             search_results = await self._client.search(search_query)
         except BandcampNotFoundError as error:
             raise MediaNotFoundError("No results for Bandcamp search") from error
+        except BandcampRateLimitError as error:
+            raise ResourceTemporarilyUnavailable(
+                "Bandcamp rate limit reached", backoff_time=error.retry_after
+            ) from error
         except BandcampAPIError as error:
             raise InvalidDataError("Unexpected error during Bandcamp search") from error
 
@@ -158,7 +173,8 @@ class BandcampProvider(MusicProvider):
             return
 
         try:
-            collection = await self._client.get_collection_items(CollectionType.COLLECTION)
+            async with self.throttler.acquire():  # AsyncGenerator method cannot be decorated
+                collection = await self._client.get_collection_items(CollectionType.COLLECTION)
             band_ids = set()
             for item in collection.items:
                 if item.item_type == "band":
@@ -175,6 +191,10 @@ class BandcampProvider(MusicProvider):
             raise LoginFailed("Wrong Bandcamp identity token.") from error
         except BandcampNotFoundError as error:
             raise MediaNotFoundError("Bandcamp library artists returned no results") from error
+        except BandcampRateLimitError as error:
+            raise ResourceTemporarilyUnavailable(
+                "Bandcamp rate limit reached", backoff_time=error.retry_after
+            ) from error
         except BandcampAPIError as error:
             raise MediaNotFoundError("Failed to get library artists") from error
 
@@ -184,7 +204,8 @@ class BandcampProvider(MusicProvider):
             return
 
         try:
-            api_collection = await self._client.get_collection_items(CollectionType.COLLECTION)
+            async with self.throttler.acquire():  # AsyncGenerator method cannot be decorated
+                api_collection = await self._client.get_collection_items(CollectionType.COLLECTION)
             for item in api_collection.items:
                 if item.item_type == "album":
                     yield await self.get_album(f"{item.band_id}-{item.item_id}")
@@ -194,6 +215,10 @@ class BandcampProvider(MusicProvider):
             raise LoginFailed("Wrong Bandcamp identity token.") from error
         except BandcampNotFoundError as error:
             raise MediaNotFoundError("Bandcamp library albums returned no results") from error
+        except BandcampRateLimitError as error:
+            raise ResourceTemporarilyUnavailable(
+                "Bandcamp rate limit reached", backoff_time=error.retry_after
+            ) from error
         except BandcampAPIError as error:
             raise MediaNotFoundError("Failed to get library albums") from error
 
@@ -202,21 +227,14 @@ class BandcampProvider(MusicProvider):
         if not self._client.identity:  # library requires identity
             return
 
-        try:
-            async for album in self.get_library_albums():
-                tracks = await self.get_album_tracks(album.item_id)
-                for track in tracks:
-                    yield track
-                    await asyncio.sleep(0)  # Yield control to avoid blocking
-        except BandcampMustBeLoggedInError as error:
-            self.logger.error("Error getting Bandcamp library tracks: Wrong identity token.")
-            raise LoginFailed("Wrong Bandcamp identity token.") from error
-        except BandcampNotFoundError as error:
-            raise MediaNotFoundError("Bandcamp library tracks returned no results") from error
-        except BandcampAPIError as error:
-            raise MediaNotFoundError("Failed to get library tracks") from error
+        async for album in self.get_library_albums():
+            tracks = await self.get_album_tracks(album.item_id)
+            for track in tracks:
+                yield track
+                await asyncio.sleep(0)  # Yield control to avoid blocking
 
     @use_cache(CACHE)
+    @throttle_with_retries
     async def get_artist(self, prov_artist_id: str | int) -> Artist:
         """Get full artist details by id."""
         try:
@@ -226,10 +244,15 @@ class BandcampProvider(MusicProvider):
             raise MediaNotFoundError(
                 f"Bandcamp artist {prov_artist_id} search returned no results"
             ) from error
+        except BandcampRateLimitError as error:
+            raise ResourceTemporarilyUnavailable(
+                "Bandcamp rate limit reached", backoff_time=error.retry_after
+            ) from error
         except BandcampAPIError as error:
             raise MediaNotFoundError(f"Failed to get artist {prov_artist_id}") from error
 
     @use_cache(CACHE)
+    @throttle_with_retries
     async def get_album(self, prov_album_id: str) -> Album:
         """Get full album details by id."""
         artist_id, album_id, _ = split_id(prov_album_id)
@@ -240,10 +263,15 @@ class BandcampProvider(MusicProvider):
             raise MediaNotFoundError(
                 f"Bandcamp album {prov_album_id} search returned no results"
             ) from error
+        except BandcampRateLimitError as error:
+            raise ResourceTemporarilyUnavailable(
+                "Bandcamp rate limit reached", backoff_time=error.retry_after
+            ) from error
         except BandcampAPIError as error:
             raise MediaNotFoundError(f"Failed to get album {prov_album_id}") from error
 
     @use_cache(CACHE)
+    @throttle_with_retries
     async def get_track(self, prov_track_id: str) -> Track:
         """Get full track details by id."""
         artist_id, album_id, track_id = split_id(prov_track_id)
@@ -273,10 +301,15 @@ class BandcampProvider(MusicProvider):
             raise MediaNotFoundError(
                 f"Bandcamp track {prov_track_id} search returned no results"
             ) from error
+        except BandcampRateLimitError as error:
+            raise ResourceTemporarilyUnavailable(
+                "Bandcamp rate limit reached", backoff_time=error.retry_after
+            ) from error
         except BandcampAPIError as error:
             raise MediaNotFoundError(f"Failed to get track {prov_track_id}") from error
 
     @use_cache(CACHE)
+    @throttle_with_retries
     async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
         """Get all tracks in an album."""
         artist_id, album_id, _ = split_id(prov_album_id)
@@ -300,10 +333,15 @@ class BandcampProvider(MusicProvider):
             raise MediaNotFoundError(
                 f"Bandcamp album {prov_album_id} tracks search returned no results"
             ) from error
+        except BandcampRateLimitError as error:
+            raise ResourceTemporarilyUnavailable(
+                "Bandcamp rate limit reached", backoff_time=error.retry_after
+            ) from error
         except BandcampAPIError as error:
             raise MediaNotFoundError(f"Failed to get albums tracks for {prov_album_id}") from error
 
     @use_cache(CACHE)
+    @throttle_with_retries
     async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
         """Get albums by an artist."""
         albums = []
@@ -326,73 +364,56 @@ class BandcampProvider(MusicProvider):
             raise MediaNotFoundError(
                 f"Bandcamp artist {prov_artist_id} albums search returned no results"
             ) from error
+        except BandcampRateLimitError as error:
+            raise ResourceTemporarilyUnavailable(
+                "Bandcamp rate limit reached", backoff_time=error.retry_after
+            ) from error
         except BandcampAPIError as error:
             raise MediaNotFoundError(f"Failed to get albums for artist {prov_artist_id}") from error
 
         return albums
 
     @use_cache(CACHE)
+    @throttle_with_retries
     async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
         """Get top tracks of an artist."""
         tracks: list[Track] = []
-        try:
-            albums = await self.get_artist_albums(prov_artist_id)
-            albums.sort(key=lambda album: (album.year is None, album.year or 0), reverse=True)
-            for album in albums:
-                tracks.extend(await self.get_album_tracks(album.item_id))
-                if len(tracks) >= self.top_tracks_limit:
-                    break
-
-        except BandcampNotFoundError as error:
-            raise MediaNotFoundError(
-                f"Bandcamp artist {prov_artist_id} top tracks search returned no results"
-            ) from error
-
-        except BandcampAPIError as error:
-            raise MediaNotFoundError(
-                f"Failed to get toptracks for artist {prov_artist_id}"
-            ) from error
+        # get_artist_albums and get_album_tracks already handle exceptions and rate limiting
+        albums = await self.get_artist_albums(prov_artist_id)
+        albums.sort(key=lambda album: (album.year is None, album.year or 0), reverse=True)
+        for album in albums:
+            tracks.extend(await self.get_album_tracks(album.item_id))
+            if len(tracks) >= self.top_tracks_limit:
+                break
 
         return tracks[: self.top_tracks_limit]
 
     async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
         """Return the content details for the given track."""
-        try:
-            # consider _client to avoid caching if the track urls become dynamic
-            track_ma = await self.get_track(item_id)
-            if not track_ma.metadata.links:
-                raise MediaNotFoundError(
-                    f"No streaming links found for track {item_id}. Please report this"
-                )
-
-            link = next(iter(track_ma.metadata.links))
-            if not link:
-                raise MediaNotFoundError(
-                    f"No streaming URL found for track {item_id}. Please report this"
-                )
-
-            streaming_url = link.url
-            if not streaming_url:
-                raise MediaNotFoundError(
-                    f"No streaming URL found for track {item_id}: {streaming_url}"
-                )
-
-            return StreamDetails(
-                item_id=item_id,
-                provider=self.instance_id,
-                audio_format=AudioFormat(),
-                stream_type=StreamType.HTTP,
-                media_type=media_type,
-                path=streaming_url,
-                can_seek=True,
-                allow_seek=True,
+        # get_track already handles exceptions and rate limiting
+        track_ma = await self.get_track(item_id)
+        if not track_ma.metadata.links:
+            raise MediaNotFoundError(
+                f"No streaming links found for track {item_id}. Please report this"
             )
 
-        except BandcampNotFoundError as error:
-            raise MediaNotFoundError(
-                f"Bandcamp stream details search for {media_type} {item_id} returned no results"
-            ) from error
-        except BandcampAPIError as error:
+        link = next(iter(track_ma.metadata.links))
+        if not link:
             raise MediaNotFoundError(
-                f"Stream details not available for {media_type} {item_id}"
-            ) from error
+                f"No streaming URL found for track {item_id}. Please report this"
+            )
+
+        streaming_url = link.url
+        if not streaming_url:
+            raise MediaNotFoundError(f"No streaming URL found for track {item_id}: {streaming_url}")
+
+        return StreamDetails(
+            item_id=item_id,
+            provider=self.instance_id,
+            audio_format=AudioFormat(),
+            stream_type=StreamType.HTTP,
+            media_type=media_type,
+            path=streaming_url,
+            can_seek=True,
+            allow_seek=True,
+        )
index 97333ff77fb6217c88907723576df0841729bcb1..9240d81fbe890b2b31efb6afdacb00455f75dd2b 100644 (file)
@@ -5,7 +5,7 @@
   "name": "Bandcamp",
   "description": "Stream music from Bandcamp's catalog.",
   "codeowners": ["@ALERTua"],
-  "requirements": ["bandcamp-async-api==0.0.5"],
+  "requirements": ["bandcamp-async-api==0.0.7"],
   "documentation": "https://music-assistant.io/music-providers/bandcamp/",
   "multi_instance": true
 }
index 19e3be39ef8e54519b1fb175f3c292a2e70f63c6..6afd613bc2489078dda45bdd598dfd3629ad6a65 100644 (file)
@@ -22,7 +22,7 @@ audible==0.10.0
 auntie-sounds==1.1.7
 av==16.1.0
 awesomeversion>=24.6.0
-bandcamp-async-api==0.0.5
+bandcamp-async-api==0.0.7
 bidict==0.23.1
 certifi==2025.11.12
 chardet>=5.2.0
index 5295613ec895eb6907d8c91bf6b39a7e120c3c46..5961d752015fa2d22aca9a5e8c0e3adef4cf21c9 100644 (file)
@@ -94,7 +94,9 @@ async def test_handle_async_init_with_identity(provider: BandcampProvider) -> No
         await provider.handle_async_init()
 
         mock_client_class.assert_called_once_with(
-            session=provider.mass.http_session, identity_token="mock_identity_token"
+            session=provider.mass.http_session,
+            identity_token="mock_identity_token",
+            default_retry_after=3,
         )
         assert provider._client == mock_client
         assert provider._converters is not None
@@ -117,7 +119,9 @@ async def test_handle_async_init_without_identity(mass_mock: Mock, manifest_mock
         await provider.handle_async_init()
 
         mock_client_class.assert_called_once_with(
-            session=provider.mass.http_session, identity_token=None
+            session=provider.mass.http_session,
+            identity_token=None,
+            default_retry_after=3,
         )