Smart Spotify rate limit detection with global ban, auto-suppression, and frontend modal

This commit is contained in:
Broque Thomas 2026-03-09 16:05:33 -07:00
parent 1ea262c749
commit eac97a6c2b
5 changed files with 355 additions and 19 deletions

View file

@ -20,6 +20,86 @@ import queue
_request_queue = queue.Queue()
_queue_processor_running = False
# Global rate limit ban state — when Spotify returns a long Retry-After (>60s),
# we set this so ALL API calls are suppressed until the ban expires.
_rate_limit_lock = threading.Lock()
_rate_limit_until = 0 # Unix timestamp when the ban expires (0 = not banned)
_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
# Threshold: if Retry-After exceeds this, activate global ban instead of sleeping
_LONG_RATE_LIMIT_THRESHOLD = 60 # seconds
class SpotifyRateLimitError(Exception):
"""Raised when Spotify API calls are blocked due to active global rate limit ban."""
def __init__(self, retry_after, endpoint=None):
self.retry_after = retry_after
self.endpoint = endpoint
super().__init__(f"Spotify rate limited for {retry_after}s (triggered by {endpoint})")
def _set_global_rate_limit(retry_after_seconds, endpoint_name):
"""Activate the global rate limit ban."""
global _rate_limit_until, _rate_limit_retry_after, _rate_limit_endpoint, _rate_limit_set_at
with _rate_limit_lock:
now = time.time()
new_until = now + retry_after_seconds
# Only update if this extends the existing ban
if new_until > _rate_limit_until:
_rate_limit_until = new_until
_rate_limit_retry_after = retry_after_seconds
_rate_limit_endpoint = endpoint_name
_rate_limit_set_at = now
logger.warning(
f"GLOBAL RATE LIMIT ACTIVATED: {retry_after_seconds}s ban "
f"(expires {time.strftime('%H:%M:%S', time.localtime(new_until))}) "
f"triggered by {endpoint_name}"
)
def _is_globally_rate_limited():
"""Check if the global rate limit ban is active."""
with _rate_limit_lock:
if _rate_limit_until <= 0:
return False
if time.time() >= _rate_limit_until:
# Ban expired — clear it
return False
return True
def _get_rate_limit_info():
"""Get current rate limit ban details. Returns None if not rate limited."""
with _rate_limit_lock:
if _rate_limit_until <= 0:
return None
now = time.time()
remaining = _rate_limit_until - now
if remaining <= 0:
return None
return {
'active': True,
'remaining_seconds': int(remaining),
'retry_after': _rate_limit_retry_after,
'endpoint': _rate_limit_endpoint,
'set_at': _rate_limit_set_at,
'expires_at': _rate_limit_until
}
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
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")
def rate_limited(func):
"""Decorator to enforce rate limiting on Spotify API calls with retry and exponential backoff"""
@wraps(func)
@ -42,12 +122,14 @@ def rate_limited(func):
try:
return func(*args, **kwargs)
except SpotifyRateLimitError:
raise # Don't retry our own ban errors
except Exception as e:
error_str = str(e).lower()
is_rate_limit = "rate limit" in error_str or "429" in str(e)
is_server_error = "502" in str(e) or "503" in str(e)
if is_rate_limit and attempt < max_retries:
if is_rate_limit:
# Try to extract Retry-After from spotipy exception headers
retry_after = None
if hasattr(e, 'headers') and e.headers:
@ -55,15 +137,26 @@ def rate_limited(func):
if retry_after:
try:
delay = int(retry_after) + 1
delay = int(retry_after)
except (ValueError, TypeError):
delay = None
# If Retry-After is long, activate global ban instead of sleeping
if delay and delay > _LONG_RATE_LIMIT_THRESHOLD:
_set_global_rate_limit(delay, func.__name__)
raise SpotifyRateLimitError(delay, func.__name__)
if delay:
delay = delay + 1
else:
delay = 3.0 * (2 ** attempt)
else:
delay = 3.0 * (2 ** attempt) # 3, 6, 12, 24, 48
logger.warning(f"Spotify rate limit hit, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
time.sleep(delay)
continue
if attempt < max_retries:
logger.warning(f"Spotify rate limit hit, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
time.sleep(delay)
continue
elif is_server_error and attempt < max_retries:
delay = 2.0 * (2 ** attempt) # 2, 4, 8, 16, 32
@ -246,7 +339,10 @@ class SpotifyClient:
cache_path='config/.spotify_cache'
)
self.sp = spotipy.Spotify(auth_manager=auth_manager)
self.sp = spotipy.Spotify(auth_manager=auth_manager, retries=0, requests_timeout=15)
# retries=0: prevent spotipy from sleeping for Retry-After duration on 429s
# (can be hours). Our rate_limited decorator + global ban handle retries instead.
# requests_timeout=15: prevent any single request from hanging indefinitely.
# Don't fetch user info on startup - do it lazily to avoid blocking UI
self.user_id = None
logger.info("Spotify client initialized (user info will be fetched when needed)")
@ -280,15 +376,20 @@ class SpotifyClient:
if self.sp is None:
return False
# If globally rate limited, report as NOT authenticated so callers
# skip Spotify and fall through to iTunes fallback naturally.
# This prevents any API calls that could extend the ban.
if _is_globally_rate_limited():
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:
return self._auth_cached_result
# Cache miss — make API call outside the lock.
# Use a no-retry client to avoid spotipy blocking for hours on 429s
# (Retry-After can be 2+ hours). The main self.sp client keeps its
# retries for normal API calls.
# Use a dedicated probe client (retries=0) so a 429 here propagates
# immediately and we can detect long Retry-After bans.
try:
probe = spotipy.Spotify(auth_manager=self.sp.auth_manager, retries=0)
probe.current_user()
@ -298,6 +399,17 @@ class SpotifyClient:
# Rate limit means we ARE authenticated — just throttled
if "rate" in error_str.lower() or "429" in error_str:
logger.warning("Spotify rate limited during auth check — treating as authenticated")
# Check if there's a Retry-After header indicating a long ban
retry_after = None
if hasattr(e, 'headers') and e.headers:
retry_after = e.headers.get('Retry-After') or e.headers.get('retry-after')
if retry_after:
try:
delay = int(retry_after)
if delay > _LONG_RATE_LIMIT_THRESHOLD:
_set_global_rate_limit(delay, 'is_spotify_authenticated')
except (ValueError, TypeError):
pass
result = True
else:
logger.debug(f"Spotify authentication check failed: {e}")
@ -310,11 +422,12 @@ class SpotifyClient:
return result
def disconnect(self):
"""Disconnect Spotify: clear client, delete cache, invalidate auth cache"""
"""Disconnect Spotify: clear client, delete cache, invalidate auth cache, clear rate limit"""
import os
self.sp = None
self.user_id = None
self._invalidate_auth_cache()
_clear_rate_limit()
cache_path = 'config/.spotify_cache'
try:
@ -325,6 +438,21 @@ class SpotifyClient:
logger.warning(f"Failed to delete Spotify cache: {e}")
logger.info("Spotify client disconnected")
@staticmethod
def is_rate_limited():
"""Check if Spotify is globally rate limited."""
return _is_globally_rate_limited()
@staticmethod
def get_rate_limit_info():
"""Get rate limit ban details. Returns None if not rate limited."""
return _get_rate_limit_info()
@staticmethod
def clear_rate_limit():
"""Manually clear the rate limit ban."""
_clear_rate_limit()
def _ensure_user_id(self) -> bool:
"""Ensure user_id is loaded (may make API call)"""

View file

@ -7,7 +7,7 @@ from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.spotify_client import SpotifyClient
from core.spotify_client import SpotifyClient, SpotifyRateLimitError
logger = get_logger("spotify_worker")
@ -97,9 +97,13 @@ class SpotifyWorker:
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
try:
authenticated = self.client.is_spotify_authenticated()
# During rate limit, is_spotify_authenticated() returns False to suppress calls,
# but we're still authenticated — just banned. Report truthfully.
rate_limited = self.client.is_rate_limited()
authenticated = rate_limited or self.client.is_spotify_authenticated()
except Exception:
authenticated = False
rate_limited = False
return {
'enabled': True,
@ -107,6 +111,7 @@ class SpotifyWorker:
'paused': self.paused,
'idle': is_idle,
'authenticated': authenticated,
'rate_limited': rate_limited,
'current_item': self.current_item,
'stats': self.stats.copy(),
'progress': progress
@ -122,6 +127,14 @@ class SpotifyWorker:
time.sleep(1)
continue
# Rate limit guard — if globally rate limited, sleep until ban expires
if self.client.is_rate_limited():
info = self.client.get_rate_limit_info()
remaining = info['remaining_seconds'] if info else 60
logger.debug(f"Spotify globally rate limited, sleeping {remaining}s...")
time.sleep(min(remaining, 60)) # Check again every 60s max
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
@ -143,6 +156,9 @@ class SpotifyWorker:
self._process_item(item)
time.sleep(self.inter_item_sleep)
except SpotifyRateLimitError:
logger.debug("Spotify rate limit hit in worker loop, will retry after ban expires")
time.sleep(10)
except Exception as e:
logger.error(f"Error in worker loop: {e}")
time.sleep(5)

View file

@ -3350,16 +3350,26 @@ 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()
# Single auth check — is_spotify_authenticated() is cached internally (60s TTL)
# 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
music_source = 'spotify' if spotify_connected else 'itunes'
# 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:
music_source = 'spotify'
else:
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
'source': music_source,
'rate_limited': rate_limit_info is not None,
'rate_limit': rate_limit_info
}
_status_cache_timestamps['spotify'] = current_time
# else: use cached value
@ -5302,12 +5312,17 @@ def spotify_disconnect():
"""Disconnect Spotify and fall back to iTunes/Apple Music"""
global spotify_client
try:
# Pause enrichment worker before disconnecting to prevent it from hammering API
if spotify_enrichment_worker:
spotify_enrichment_worker.pause()
spotify_client.disconnect()
# Immediately update status cache so UI reflects the change
_status_cache['spotify'] = {
'connected': True, # iTunes fallback is always available
'response_time': 0,
'source': 'itunes'
'source': 'itunes',
'rate_limited': False,
'rate_limit': None
}
_status_cache_timestamps['spotify'] = time.time()
add_activity_item("🔌", "Spotify Disconnected", "Switched to Apple Music/iTunes metadata source", "Now")
@ -5317,6 +5332,25 @@ def spotify_disconnect():
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/spotify/rate-limit-status', methods=['GET'])
def spotify_rate_limit_status():
"""Get Spotify rate limit ban details"""
try:
info = spotify_client.get_rate_limit_info()
if info:
return jsonify({
'rate_limited': True,
'remaining_seconds': info['remaining_seconds'],
'retry_after': info['retry_after'],
'endpoint': info['endpoint'],
'expires_at': info['expires_at']
})
return jsonify({'rate_limited': False})
except Exception as e:
logger.error(f"Error getting Spotify rate limit status: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/tidal/callback')
def tidal_callback():
"""
@ -34160,8 +34194,15 @@ def _build_status_payload():
download_mode = config_manager.get('download_source.mode', 'soulseek')
soulseek_data = dict(_status_cache.get('soulseek', {}))
soulseek_data['source'] = download_mode
# Always include fresh rate limit info (it changes over time as ban expires)
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
spotify_data['rate_limit'] = rate_limit_info
return {
'spotify': _status_cache.get('spotify', {}),
'spotify': spotify_data,
'media_server': _status_cache.get('media_server', {}),
'soulseek': soulseek_data,
'active_media_server': config_manager.get_active_media_server()

View file

@ -4346,6 +4346,42 @@
</div>
</div>
<!-- Spotify Rate Limit Modal -->
<div class="modal-overlay hidden" id="spotify-rate-limit-overlay" onclick="closeSpotifyRateLimitModal()">
<div class="support-modal" onclick="event.stopPropagation()" style="max-width: 480px;">
<div class="support-modal-header">
<h2>Spotify Rate Limited</h2>
<button class="support-modal-close" onclick="closeSpotifyRateLimitModal()">&times;</button>
</div>
<div class="support-modal-body" style="padding: 20px;">
<p style="margin: 0 0 12px; color: var(--text-secondary); line-height: 1.5;">
Spotify has temporarily blocked API access. All Spotify features (search, enrichment, playlists) are paused until the ban expires.
</p>
<div style="background: var(--bg-tertiary); border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span style="color: var(--text-secondary);">Time remaining</span>
<span id="rate-limit-remaining" style="color: var(--accent); font-weight: 600;">--</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span style="color: var(--text-secondary);">Original ban duration</span>
<span id="rate-limit-duration" style="color: var(--text-primary);">--</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-secondary);">Triggered by</span>
<span id="rate-limit-endpoint" style="color: var(--text-primary); font-family: monospace; font-size: 0.85em;">--</span>
</div>
</div>
<p style="margin: 0 0 16px; color: var(--text-tertiary); font-size: 0.85em; line-height: 1.5;">
You can wait for the ban to expire, or disconnect Spotify entirely and switch to Apple Music/iTunes for metadata.
</p>
<div style="display: flex; gap: 10px;">
<button onclick="closeSpotifyRateLimitModal()" style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer;">Wait It Out</button>
<button onclick="disconnectSpotifyFromRateLimitModal()" style="flex: 1; padding: 10px; border-radius: 8px; border: none; background: var(--danger, #e74c3c); color: white; cursor: pointer; font-weight: 600;">Disconnect Spotify</button>
</div>
</div>
</div>
</div>
<!-- Add to Wishlist Modal -->
<div class="modal-overlay hidden" id="add-to-wishlist-modal-overlay">
<div class="add-to-wishlist-modal" id="add-to-wishlist-modal">

View file

@ -280,6 +280,13 @@ function handleServiceStatusUpdate(data) {
updateSidebarServiceStatus('spotify', data.spotify);
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
// Check for Spotify rate limit
if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
handleSpotifyRateLimit(data.spotify.rate_limit);
} else if (_spotifyRateLimitShown) {
handleSpotifyRateLimit(null);
}
}
function handleWatchlistCountUpdate(data) {
@ -5060,6 +5067,97 @@ async function disconnectSpotify() {
}
}
// ── Spotify Rate Limit Modal ──────────────────────────────────────────────
let _spotifyRateLimitShown = false;
let _rateLimitCountdownInterval = null;
let _rateLimitRemainingSeconds = 0; // Local countdown, synced from server updates
function handleSpotifyRateLimit(rateLimitInfo) {
if (!rateLimitInfo || !rateLimitInfo.active) {
// Rate limit cleared — hide modal if showing
if (_spotifyRateLimitShown) {
_spotifyRateLimitShown = false;
closeSpotifyRateLimitModal();
showToast('Spotify rate limit expired — API access restored', 'success');
}
return;
}
// Sync local countdown from server's authoritative remaining_seconds
_rateLimitRemainingSeconds = rateLimitInfo.remaining_seconds;
// Update static fields (only change on new ban events)
const durationEl = document.getElementById('rate-limit-duration');
const endpointEl = document.getElementById('rate-limit-endpoint');
if (durationEl) durationEl.textContent = formatRateLimitDuration(rateLimitInfo.retry_after);
if (endpointEl) endpointEl.textContent = rateLimitInfo.endpoint || 'unknown';
// Update remaining display
const remainingEl = document.getElementById('rate-limit-remaining');
if (remainingEl) remainingEl.textContent = formatRateLimitDuration(_rateLimitRemainingSeconds);
// Show modal and start countdown once per rate limit event
if (!_spotifyRateLimitShown) {
_spotifyRateLimitShown = true;
const overlay = document.getElementById('spotify-rate-limit-overlay');
if (overlay) overlay.classList.remove('hidden');
// Start local 1s countdown for smooth display between server updates
if (_rateLimitCountdownInterval) clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = setInterval(() => {
_rateLimitRemainingSeconds--;
if (_rateLimitRemainingSeconds <= 0) {
_spotifyRateLimitShown = false;
closeSpotifyRateLimitModal();
showToast('Spotify rate limit expired — API access restored', 'success');
return;
}
const el = document.getElementById('rate-limit-remaining');
if (el) el.textContent = formatRateLimitDuration(_rateLimitRemainingSeconds);
}, 1000);
}
}
function formatRateLimitDuration(seconds) {
if (!seconds || seconds <= 0) return '0s';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function closeSpotifyRateLimitModal() {
const overlay = document.getElementById('spotify-rate-limit-overlay');
if (overlay) overlay.classList.add('hidden');
if (_rateLimitCountdownInterval) {
clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = null;
}
}
async function disconnectSpotifyFromRateLimitModal() {
closeSpotifyRateLimitModal();
_spotifyRateLimitShown = false;
try {
showLoadingOverlay('Disconnecting Spotify...');
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast('Spotify disconnected. Now using Apple Music/iTunes.', 'success');
await fetchAndUpdateServiceStatus();
} else {
showToast(`Failed to disconnect: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error disconnecting Spotify:', error);
showToast('Failed to disconnect Spotify', 'error');
} finally {
hideLoadingOverlay();
}
}
async function authenticateTidal() {
try {
showLoadingOverlay('Starting Tidal authentication...');
@ -29188,6 +29286,13 @@ async function fetchAndUpdateServiceStatus() {
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
// Check for Spotify rate limit
if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
handleSpotifyRateLimit(data.spotify.rate_limit);
} else if (_spotifyRateLimitShown) {
handleSpotifyRateLimit(null);
}
} catch (error) {
console.warn('Could not fetch service status:', error);
}
@ -29198,7 +29303,12 @@ function updateServiceStatus(service, statusData) {
const statusText = document.getElementById(`${service}-status-text`);
if (indicator && statusText) {
if (statusData.connected) {
if (service === 'spotify' && statusData.rate_limited) {
indicator.className = 'service-card-indicator disconnected';
const remaining = statusData.rate_limit ? formatRateLimitDuration(statusData.rate_limit.remaining_seconds) : '';
statusText.textContent = `Rate Limited${remaining ? ' (' + remaining + ')' : ''}`;
statusText.className = 'service-card-status-text disconnected';
} else if (statusData.connected) {
indicator.className = 'service-card-indicator connected';
statusText.textContent = `Connected (${statusData.response_time}ms)`;
statusText.className = 'service-card-status-text connected';
@ -29242,10 +29352,15 @@ function updateSidebarServiceStatus(service, statusData) {
const nameElement = indicator.querySelector('.status-name');
if (dot) {
if (statusData.connected) {
if (service === 'spotify' && statusData.rate_limited) {
dot.className = 'status-dot disconnected';
dot.title = 'Rate Limited';
} else if (statusData.connected) {
dot.className = 'status-dot connected';
dot.title = '';
} else {
dot.className = 'status-dot disconnected';
dot.title = '';
}
}