Defer all provider API probes during boot to prevent startup hangs.

Introduce a boot-phase guard so gunicorn worker import never blocks on Spotify, Qobuz, Deezer, or Tidal network validation. Network auth checks run only after module initialization completes.
This commit is contained in:
Siddharth Pradhan 2026-06-29 12:17:44 -04:00
parent 9b18e99419
commit 9fc3628062
10 changed files with 240 additions and 4 deletions

27
core/boot_phase.py Normal file
View file

@ -0,0 +1,27 @@
"""Boot-phase guard for non-blocking container startup.
While the gunicorn worker is importing ``web_server`` (module-level client and
worker initialization), external provider API probes must not block startup.
Network validation is deferred until ``mark_boot_complete()`` runs at the end
of that import pass.
"""
from __future__ import annotations
import threading
_boot_lock = threading.Lock()
_boot_active = True
def is_boot_phase() -> bool:
"""Return True while module import must avoid blocking provider API calls."""
with _boot_lock:
return _boot_active
def mark_boot_complete() -> None:
"""End the boot phase — provider clients may perform network probes again."""
global _boot_active
with _boot_lock:
_boot_active = False

View file

@ -121,6 +121,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
self._license_token = None self._license_token = None
self._user_data = None self._user_data = None
self._authenticated = False self._authenticated = False
self._pending_arl: Optional[str] = None
# Quality preference # Quality preference
self._quality = quality_tier_for_source('deezer', default='flac') self._quality = quality_tier_for_source('deezer', default='flac')
@ -128,7 +129,12 @@ class DeezerDownloadClient(DownloadSourcePlugin):
# Try to authenticate on init if ARL is configured # Try to authenticate on init if ARL is configured
arl = config_manager.get('deezer_download.arl', '') arl = config_manager.get('deezer_download.arl', '')
if arl: if arl:
self._authenticate(arl) from core.boot_phase import is_boot_phase
if is_boot_phase():
self._pending_arl = arl
logger.debug("Deezer ARL present — authentication deferred until after boot")
else:
self._authenticate(arl)
logger.info(f"Deezer download client initialized (download path: {self.download_path})") logger.info(f"Deezer download client initialized (download path: {self.download_path})")
@ -227,6 +233,11 @@ class DeezerDownloadClient(DownloadSourcePlugin):
return self._authenticated return self._authenticated
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
if self._pending_arl and not self._authenticated:
from core.boot_phase import is_boot_phase
if not is_boot_phase():
self._authenticate(self._pending_arl)
self._pending_arl = None
return self._authenticated return self._authenticated
async def check_connection(self) -> bool: async def check_connection(self) -> bool:

View file

@ -365,6 +365,11 @@ def get_configured_primary_source() -> str:
def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str: def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str:
"""Return configured primary metadata source.""" """Return configured primary metadata source."""
from core.boot_phase import is_boot_phase
if is_boot_phase():
return get_configured_primary_source()
_default = METADATA_SOURCE_PRIORITY[0] _default = METADATA_SOURCE_PRIORITY[0]
source = get_configured_primary_source() source = get_configured_primary_source()
@ -453,7 +458,19 @@ def get_primary_source_status(
musicbrainz_client_factory: Optional[MetadataClientFactory] = None, musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Return a generic status snapshot for the active primary metadata source.""" """Return a generic status snapshot for the active primary metadata source."""
from core.boot_phase import is_boot_phase
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer" source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
if is_boot_phase():
display_source = source
if source == "spotify" and _get_config_value("metadata.spotify_free", False):
display_source = "spotify_free"
return {
"source": display_source,
"connected": False,
"response_time": 0,
}
started = time.time() started = time.time()
connected = False connected = False
@ -521,9 +538,13 @@ def get_client_for_source(
musicbrainz_client_factory: Optional[MetadataClientFactory] = None, musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
): ):
"""Return exact client for a source, or None if unavailable.""" """Return exact client for a source, or None if unavailable."""
from core.boot_phase import is_boot_phase
if source == "spotify": if source == "spotify":
try: try:
client = get_spotify_client(client_factory=spotify_client_factory) client = get_spotify_client(client_factory=spotify_client_factory)
if is_boot_phase():
return client if client and getattr(client, "sp", None) else None
if client and client.is_spotify_authenticated(): if client and client.is_spotify_authenticated():
return client return client
except Exception as e: except Exception as e:

View file

@ -164,6 +164,8 @@ class QobuzClient(DownloadSourcePlugin):
def _restore_session(self): def _restore_session(self):
"""Try to restore saved session from config.""" """Try to restore saved session from config."""
from core.boot_phase import is_boot_phase
saved = config_manager.get('qobuz.session', {}) saved = config_manager.get('qobuz.session', {})
app_id = saved.get('app_id', '') app_id = saved.get('app_id', '')
app_secret = saved.get('app_secret', '') app_secret = saved.get('app_secret', '')
@ -178,6 +180,10 @@ class QobuzClient(DownloadSourcePlugin):
'X-User-Auth-Token': self.user_auth_token, 'X-User-Auth-Token': self.user_auth_token,
}) })
if is_boot_phase():
logger.info("Loaded Qobuz session from config (verification deferred until after boot)")
return
# Verify the token is still valid # Verify the token is still valid
try: try:
resp = self.session.get( resp = self.session.get(

View file

@ -751,6 +751,16 @@ class SpotifyClient:
self._auth_cached_result = None self._auth_cached_result = None
self._auth_cache_time = 0 self._auth_cache_time = 0
def _has_cached_oauth_token(self) -> bool:
"""Return True when a persisted OAuth token exists (no network I/O)."""
if self.sp is None:
return False
try:
cache_handler = getattr(self.sp.auth_manager, 'cache_handler', None)
return bool(cache_handler and cache_handler.get_cached_token() is not None)
except Exception:
return False
def is_spotify_authenticated(self) -> bool: def is_spotify_authenticated(self) -> bool:
"""Check if Spotify client is specifically authenticated (not just iTunes fallback). """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.
@ -826,6 +836,26 @@ class SpotifyClient:
logger.debug("publish_spotify_status cache hit: %s", e) logger.debug("publish_spotify_status cache hit: %s", e)
return self._auth_cached_result return self._auth_cached_result
from core.boot_phase import is_boot_phase
if is_boot_phase():
result = self._has_cached_oauth_token()
with self._auth_cache_lock:
self._auth_cached_result = result
self._auth_cache_time = time.time()
try:
from core.metadata.status import publish_spotify_status
publish_spotify_status(
connected=result,
authenticated=result,
rate_limited=False,
rate_limit=None,
post_ban_cooldown=None,
)
except Exception as e:
logger.debug("publish_spotify_status boot-phase: %s", e)
return result
# Cache miss — make API call outside the lock. # Cache miss — make API call outside the lock.
# Safety: if there's no cached token, return False immediately. # Safety: if there's no cached token, return False immediately.
# Without this guard, spotipy's auth_manager will try to start an interactive # Without this guard, spotipy's auth_manager will try to start an interactive

View file

@ -520,10 +520,14 @@ class TidalClient:
return True return True
def is_authenticated(self): def is_authenticated(self) -> bool:
"""Check if client is authenticated, refreshing expired tokens if possible""" """Check if client is authenticated, refreshing expired tokens if possible"""
if self.access_token and time.time() < self.token_expires_at: from core.boot_phase import is_boot_phase
return True
if is_boot_phase():
if self.access_token and time.time() < self.token_expires_at:
return True
return bool(self.access_token and self.refresh_token)
# Backoff: if refresh recently failed, don't retry for 5 minutes # Backoff: if refresh recently failed, don't retry for 5 minutes
if hasattr(self, '_refresh_failed_at') and self._refresh_failed_at: if hasattr(self, '_refresh_failed_at') and self._refresh_failed_at:

View file

@ -134,6 +134,7 @@ class TidalDownloadClient(DownloadSourcePlugin):
self._device_auth_future = None self._device_auth_future = None
self._device_auth_link = None self._device_auth_link = None
self._boot_session_tokens: Optional[dict] = None
# Engine reference is populated by set_engine() at registration # Engine reference is populated by set_engine() at registration
# time. Until then dispatch returns None — orchestrator wires # time. Until then dispatch returns None — orchestrator wires
@ -163,6 +164,14 @@ class TidalDownloadClient(DownloadSourcePlugin):
expiry_time = saved.get('expiry_time', 0) expiry_time = saved.get('expiry_time', 0)
if token_type and access_token: if token_type and access_token:
from core.boot_phase import is_boot_phase
if is_boot_phase():
self._boot_session_tokens = saved
logger.info(
"Loaded Tidal download session from config (verification deferred until after boot)"
)
return
try: try:
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
@ -181,6 +190,39 @@ class TidalDownloadClient(DownloadSourcePlugin):
except Exception as e: except Exception as e:
logger.warning(f"Could not restore Tidal session: {e}") logger.warning(f"Could not restore Tidal session: {e}")
def _complete_deferred_session(self) -> bool:
"""Finish restoring a session that was deferred during boot."""
pending = getattr(self, '_boot_session_tokens', None)
if not pending or tidalapi is None:
self._boot_session_tokens = None
return False
if not self.session:
self.session = tidalapi.Session()
token_type = pending.get('token_type', '')
access_token = pending.get('access_token', '')
refresh_token = pending.get('refresh_token', '')
expiry_time = pending.get('expiry_time', 0)
self._boot_session_tokens = None
try:
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
restored = self.session.load_oauth_session(
token_type=token_type,
access_token=access_token,
refresh_token=refresh_token,
expiry_time=expiry_dt,
)
if restored and self.session.check_login():
logger.info("Restored Tidal download session from saved tokens")
self._save_session()
return True
logger.warning("Saved Tidal session tokens are invalid/expired")
except Exception as e:
logger.warning(f"Could not restore Tidal session: {e}")
return False
def _save_session(self): def _save_session(self):
if not self.session: if not self.session:
return return
@ -192,6 +234,14 @@ class TidalDownloadClient(DownloadSourcePlugin):
}) })
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
from core.boot_phase import is_boot_phase
if is_boot_phase():
pending = getattr(self, '_boot_session_tokens', None)
return bool(pending and pending.get('access_token'))
if getattr(self, '_boot_session_tokens', None):
return self._complete_deferred_session()
if not self.session: if not self.session:
return False return False
try: try:

View file

@ -0,0 +1,77 @@
"""Boot-phase guards must defer blocking provider network probes."""
from unittest.mock import MagicMock, patch
from core.boot_phase import is_boot_phase, mark_boot_complete
from core.metadata import registry
def setup_function():
mark_boot_complete()
def teardown_function():
mark_boot_complete()
def test_get_primary_source_skips_spotify_probe_during_boot(monkeypatch):
import core.boot_phase as boot_phase
boot_phase._boot_active = True
monkeypatch.setattr(registry, "get_configured_primary_source", lambda: "spotify")
with patch.object(registry, "get_spotify_client") as get_client:
assert registry.get_primary_source() == "spotify"
get_client.assert_not_called()
def test_get_primary_source_status_skips_client_probe_during_boot(monkeypatch):
import core.boot_phase as boot_phase
boot_phase._boot_active = True
monkeypatch.setattr(
registry, "_get_config_value",
lambda key, default=None: "spotify" if key == "metadata.fallback_source" else default,
)
with patch.object(registry, "get_client_for_source") as get_client:
status = registry.get_primary_source_status()
get_client.assert_not_called()
assert status["source"] == "spotify"
assert status["connected"] is False
def test_spotify_auth_uses_token_presence_only_during_boot(monkeypatch):
import core.boot_phase as boot_phase
from core.spotify_client import SpotifyClient
boot_phase._boot_active = True
client = SpotifyClient.__new__(SpotifyClient)
client.sp = MagicMock()
client._auth_cache_lock = __import__('threading').Lock()
client._auth_cached_result = None
client._auth_cache_time = 0
client._AUTH_CACHE_TTL = 900
monkeypatch.setattr(client, "_has_cached_oauth_token", lambda: True)
with patch("spotipy.Spotify") as spotify_cls:
assert client.is_spotify_authenticated() is True
spotify_cls.assert_not_called()
def test_deezer_download_defers_arl_auth_during_boot(monkeypatch):
import core.boot_phase as boot_phase
from core.deezer_download_client import DeezerDownloadClient
boot_phase._boot_active = True
monkeypatch.setattr(
"config.settings.config_manager.get",
lambda key, default=None: "fake-arl" if key == "deezer_download.arl" else default,
)
with patch.object(DeezerDownloadClient, "_authenticate") as authenticate:
client = DeezerDownloadClient(download_path="/tmp/deezer-test")
authenticate.assert_not_called()
assert client._pending_arl == "fake-arl"
assert client.is_authenticated() is False

View file

@ -2,9 +2,14 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from core.boot_phase import mark_boot_complete
from core.metadata import registry from core.metadata import registry
def setup_function():
mark_boot_complete()
def test_get_configured_primary_source_reads_config_without_auth_probe(monkeypatch): def test_get_configured_primary_source_reads_config_without_auth_probe(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
registry, registry,

View file

@ -38728,6 +38728,11 @@ def start_runtime_services():
_runtime_started = True _runtime_started = True
# Module import is complete — provider clients may now perform network probes.
from core.boot_phase import mark_boot_complete
mark_boot_complete()
# Direct execution: python web_server.py (dev/Windows fallback) # Direct execution: python web_server.py (dev/Windows fallback)
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application # Production should use: gunicorn -c gunicorn.conf.py wsgi:application
if _DIRECT_RUN: if _DIRECT_RUN: