Spotify rate limit re-trigger loop caused by periodic auth probes

pull/253/head
Broque Thomas 2 months ago
parent 0aa8950436
commit bde2be1cfa

@ -27,10 +27,16 @@ _rate_limit_until = 0 # Unix timestamp when the ban expires (0 = not banne
_rate_limit_retry_after = 0 # Original Retry-After value in seconds
_rate_limit_endpoint = None # Which function triggered the ban
_rate_limit_set_at = 0 # When the ban was set
_rate_limit_ban_ended_at = 0 # When the last ban expired naturally (for post-ban cooldown)
# Threshold: if Retry-After exceeds this, activate global ban instead of sleeping
_LONG_RATE_LIMIT_THRESHOLD = 60 # seconds
# After a ban expires, wait this long before making any auth probe calls.
# This prevents the "immediate re-probe → re-ban" cycle where Spotify's server-side
# cooldown outlasts the Retry-After value they sent us.
_POST_BAN_COOLDOWN = 300 # 5 minutes
class SpotifyRateLimitError(Exception):
"""Raised when Spotify API calls are blocked due to active global rate limit ban."""
@ -61,15 +67,41 @@ def _set_global_rate_limit(retry_after_seconds, endpoint_name):
def _is_globally_rate_limited():
"""Check if the global rate limit ban is active."""
global _rate_limit_ban_ended_at
with _rate_limit_lock:
if _rate_limit_until <= 0:
return False
if time.time() >= _rate_limit_until:
# Ban expired — clear it
# Ban expired — record when it ended so post-ban cooldown can apply
if _rate_limit_ban_ended_at < _rate_limit_until:
_rate_limit_ban_ended_at = time.time()
logger.info("Rate limit ban expired, entering post-ban cooldown period")
return False
return True
def _is_in_post_ban_cooldown():
"""Check if we're in the post-ban cooldown period.
After a ban expires, we wait _POST_BAN_COOLDOWN seconds before allowing
auth probes to prevent the re-probe re-ban cycle."""
with _rate_limit_lock:
if _rate_limit_ban_ended_at <= 0:
return False
elapsed = time.time() - _rate_limit_ban_ended_at
if elapsed < _POST_BAN_COOLDOWN:
return True
return False
def _get_post_ban_cooldown_remaining():
"""Get remaining seconds in post-ban cooldown, or 0 if not in cooldown."""
with _rate_limit_lock:
if _rate_limit_ban_ended_at <= 0:
return 0
remaining = _POST_BAN_COOLDOWN - (time.time() - _rate_limit_ban_ended_at)
return max(0, int(remaining))
def _get_rate_limit_info():
"""Get current rate limit ban details. Returns None if not rate limited."""
with _rate_limit_lock:
@ -90,14 +122,16 @@ def _get_rate_limit_info():
def _clear_rate_limit():
"""Manually clear the global rate limit ban (e.g. after disconnect/reconnect)."""
global _rate_limit_until, _rate_limit_retry_after, _rate_limit_endpoint, _rate_limit_set_at
"""Manually clear the global rate limit ban AND post-ban cooldown.
Used by disconnect/reconnect so the user can immediately retry."""
global _rate_limit_until, _rate_limit_retry_after, _rate_limit_endpoint, _rate_limit_set_at, _rate_limit_ban_ended_at
with _rate_limit_lock:
_rate_limit_until = 0
_rate_limit_retry_after = 0
_rate_limit_endpoint = None
_rate_limit_set_at = 0
logger.info("Global rate limit ban cleared")
_rate_limit_ban_ended_at = 0
logger.info("Global rate limit ban cleared (including post-ban cooldown)")
def rate_limited(func):
@ -372,7 +406,8 @@ class SpotifyClient:
def is_spotify_authenticated(self) -> bool:
"""Check if Spotify client is specifically authenticated (not just iTunes fallback).
Results are cached for 60 seconds to avoid excessive API calls."""
Results are cached for 60 seconds to avoid excessive API calls.
During rate limit bans and post-ban cooldown, returns False without making API calls."""
if self.sp is None:
return False
@ -382,6 +417,14 @@ class SpotifyClient:
if _is_globally_rate_limited():
return False
# Post-ban cooldown: after a ban expires, don't probe Spotify immediately.
# Spotify's server-side cooldown can outlast the Retry-After they sent us,
# so probing right away would just re-trigger the ban.
if _is_in_post_ban_cooldown():
remaining = _get_post_ban_cooldown_remaining()
logger.debug(f"Post-ban cooldown active ({remaining}s left), skipping auth probe")
return False
# Check cache first (lock only for brief read)
with self._auth_cache_lock:
if self._auth_cached_result is not None and (time.time() - self._auth_cache_time) < self._AUTH_CACHE_TTL:
@ -453,7 +496,12 @@ class SpotifyClient:
def clear_rate_limit():
"""Manually clear the rate limit ban."""
_clear_rate_limit()
@staticmethod
def get_post_ban_cooldown_remaining():
"""Get remaining seconds in post-ban cooldown, or 0 if not in cooldown."""
return _get_post_ban_cooldown_remaining()
def _ensure_user_id(self) -> bool:
"""Ensure user_id is loaded (may make API call)"""
if self.user_id is None and self.sp is not None:

@ -97,13 +97,19 @@ class SpotifyWorker:
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
try:
# During rate limit, is_spotify_authenticated() returns False to suppress calls,
# but we're still authenticated — just banned. Report truthfully.
# During rate limit or post-ban cooldown, report as authenticated
# but don't call is_spotify_authenticated() — that would trigger an
# auth probe which resets the rate limit timer.
rate_limited = self.client.is_rate_limited()
authenticated = rate_limited or self.client.is_spotify_authenticated()
in_cooldown = self.client.get_post_ban_cooldown_remaining() > 0
if rate_limited or in_cooldown:
authenticated = True # We're authenticated, just banned/cooling down
else:
authenticated = self.client.is_spotify_authenticated()
except Exception:
authenticated = False
rate_limited = False
in_cooldown = False
return {
'enabled': True,
@ -135,6 +141,14 @@ class SpotifyWorker:
time.sleep(min(remaining, 60)) # Check again every 60s max
continue
# Post-ban cooldown guard — after ban expires, wait before resuming
# to avoid immediately re-triggering the rate limit
cooldown = self.client.get_post_ban_cooldown_remaining()
if cooldown > 0:
logger.debug(f"Post-ban cooldown active ({cooldown}s left), sleeping...")
time.sleep(min(cooldown, 60))
continue
# Auth guard — don't process anything without Spotify auth
if not self.client.is_spotify_authenticated():
# Try reloading config in case user re-authenticated via settings

@ -3529,26 +3529,28 @@ def get_status():
# Test Spotify - with caching to avoid excessive API calls
if current_time - _status_cache_timestamps['spotify'] > STATUS_CACHE_TTL:
spotify_start = time.time()
# Auth check first — may detect and set a new rate limit ban via probe
spotify_connected = spotify_client.is_spotify_authenticated()
spotify_response_time = (time.time() - spotify_start) * 1000
# Check rate limit AFTER auth (auth probe may have just set the ban)
rate_limit_info = spotify_client.get_rate_limit_info()
# During rate limit, is_spotify_authenticated() returns False to suppress calls,
# but we still report source as 'spotify' so UI doesn't flip to "Apple Music"
if rate_limit_info:
# Check is_rate_limited() first — this calls _is_globally_rate_limited() which
# records ban-end timestamp if the ban just expired, enabling cooldown detection.
is_rate_limited = spotify_client.is_rate_limited()
rate_limit_info = spotify_client.get_rate_limit_info() if is_rate_limited else None
cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining()
if is_rate_limited or cooldown_remaining > 0:
# During rate limit or post-ban cooldown, skip the auth probe entirely.
# Probing Spotify here would reset the rate limit timer.
music_source = 'spotify'
spotify_response_time = 0
else:
spotify_start = time.time()
spotify_connected = spotify_client.is_spotify_authenticated()
spotify_response_time = (time.time() - spotify_start) * 1000
music_source = 'spotify' if spotify_connected else 'itunes'
_status_cache['spotify'] = {
'connected': True, # Always true — iTunes fallback is always available
'response_time': round(spotify_response_time, 1),
'source': music_source,
'rate_limited': rate_limit_info is not None,
'rate_limited': is_rate_limited,
'rate_limit': rate_limit_info
}
_status_cache_timestamps['spotify'] = current_time
@ -34849,10 +34851,15 @@ def _build_status_payload():
soulseek_data['source'] = download_mode
# Always include fresh rate limit info (it changes over time as ban expires)
# Call is_rate_limited() first to ensure ban-end timestamp is recorded for cooldown
spotify_data = dict(_status_cache.get('spotify', {}))
rate_limit_info = spotify_client.get_rate_limit_info() if spotify_client else None
spotify_data['rate_limited'] = rate_limit_info is not None
is_rl = spotify_client.is_rate_limited() if spotify_client else False
rate_limit_info = spotify_client.get_rate_limit_info() if is_rl else None
cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0
spotify_data['rate_limited'] = is_rl
spotify_data['rate_limit'] = rate_limit_info
if cooldown_remaining > 0:
spotify_data['post_ban_cooldown'] = cooldown_remaining
return {
'spotify': spotify_data,

Loading…
Cancel
Save