Bridge the Spotify worker to Free when the daily budget is spent (don't pause)

#798 follow-up. The worker's 500/day budget is a REAL-API ban shield, but
when it was hit the worker paused outright — even for a Spotify-Free user
with the uncapped free source available. So "I'm on Spotify Free" still
got capped overnight. The intuition is right: if it's ever using Spotify
Free, the budget shouldn't apply.

Fix: spent budget now becomes a third "use free" trigger (alongside
no-auth and rate-limited). When the real-API budget is exhausted and the
free source is available, the worker switches to free (uncapped) for the
rest of the day instead of pausing, then reverts to real-first on the
daily reset.

- should_use_free_fallback gains a budget_exhausted arg (free activates on
  no-auth OR rate-limited OR spent-budget).
- the worker sets _budget_exhausted_use_free on ITS OWN client (a separate
  instance from the search client — verified, so user searches still use
  real auth), and clears it when the budget resets; _free_active() honors
  the flag.
- get_stats() using_free reports the budget-bridge too, and the dashboard
  bubble shows "Running (Spotify Free)" instead of "Daily Limit Reached"
  (budgetStuck = exhausted AND not bridging).

A no-free user still pauses on the budget (nothing to bridge to). A pure
free-only worker never increments the budget at all. New gate test pins
the budget_exhausted trigger. Full suite clean.
This commit is contained in:
BoulderBadgeDad 2026-06-06 08:51:30 -07:00
parent 971f2fd4f0
commit e5e56f3d06
5 changed files with 72 additions and 23 deletions

View file

@ -612,9 +612,12 @@ class SpotifyClient:
term covers the brief window before the auth cache refreshes. When authed
+ healthy the official path returns first, so this never opens.
Two activations fall out of this: a no-auth user who chose Spotify Free
(free is their source), and a connected user mid-rate-limit (free bridges
the ban) see _free_wanted()."""
Three activations fall out of this: a no-auth user who chose Spotify
Free (free is their source), a connected user mid-rate-limit (free
bridges the ban), and a connected user who has spent the enrichment
worker's real-API daily budget (``_budget_exhausted_use_free``, set by
the worker) so a Spotify-Free user is never paused by the budget, it
just switches to the uncapped free source. See _free_wanted()."""
from core.spotify_free_metadata import should_use_free_fallback
if not self._free_available():
return False
@ -622,7 +625,10 @@ class SpotifyClient:
authed = self.is_spotify_authenticated()
except Exception:
authed = False
return should_use_free_fallback(authed, _is_globally_rate_limited())
return should_use_free_fallback(
authed, _is_globally_rate_limited(),
getattr(self, '_budget_exhausted_use_free', False),
)
@property
def _free_meta(self):

View file

@ -46,13 +46,17 @@ def spotify_free_installed() -> bool:
return _installed_cache
def should_use_free_fallback(authenticated: bool, rate_limited: bool) -> bool:
def should_use_free_fallback(authenticated: bool, rate_limited: bool,
budget_exhausted: bool = False) -> bool:
"""The per-request gate: the no-creds SpotipyFree source may serve a request
ONLY when official Spotify can't — i.e. the user has no Spotify auth, or
we're currently rate-limited. When authed AND healthy the official path
returns before any fallback, so this never opens.
ONLY when official Spotify can't — i.e. the user has no Spotify auth, we're
currently rate-limited, OR the worker's self-imposed real-API daily budget
is spent. The daily budget is a real-API ban protection; once it's hit, the
free source (which isn't subject to it) is the natural place to keep going,
so a Spotify-Free user is never paused by the budget. When authed AND
healthy AND under budget, the official path returns before any fallback.
"""
return (not authenticated) or rate_limited
return (not authenticated) or rate_limited or budget_exhausted
def should_offer_spotify_metadata(authenticated: bool, free_available: bool) -> bool:

View file

@ -126,13 +126,17 @@ class SpotifyWorker:
rate_limit_info = self.client.get_rate_limit_info() if rate_limited else None
in_cooldown = self.client.get_post_ban_cooldown_remaining() > 0
authenticated = self.client.sp is not None
# Is the worker still serving via the no-creds Spotify Free source
# despite the real-API ban? Only check WHEN rate-limited: during a
# ban is_spotify_authenticated() returns False without an API probe,
# so is_spotify_metadata_available() reduces to "is free available"
# (no quota cost). Lets the UI show "via Spotify Free" instead of a
# misleading "rate limited / waiting" while the worker keeps matching.
using_free = bool(rate_limited and self.client.is_spotify_metadata_available())
# Is the worker serving via the no-creds Spotify Free source right
# now? Two cases: bridging a rate-limit ban (only checked WHEN
# rate-limited — there is_spotify_authenticated() returns False
# without an API probe, so this is quota-free), OR bridging the spent
# real-API daily budget (the worker set _budget_exhausted_use_free —
# a cheap attribute read). Lets the UI show "Running (Spotify Free)"
# instead of a misleading "rate limited" / "daily limit reached".
using_free = bool(
(rate_limited and self.client.is_spotify_metadata_available())
or getattr(self.client, '_budget_exhausted_use_free', False)
)
except Exception:
authenticated = False
rate_limited = False
@ -214,14 +218,35 @@ class SpotifyWorker:
# protect the REAL authenticated API from bans — they don't apply
# to free (a different, anonymous path). Computed once and reused
# below; the loop already probes auth, so no extra quota cost.
budget_exhausted = self._is_daily_budget_exhausted()
# Daily budget is a REAL-API ban protection. When it's spent, if
# the no-creds free source is available, BRIDGE to it (uncapped)
# for the rest of the day instead of pausing — so a Spotify-Free
# user is never stopped by the budget. The flag makes the client
# route subsequent calls to free; it clears on the daily reset.
try:
if budget_exhausted and self.client._free_available():
self.client._budget_exhausted_use_free = True
elif not budget_exhausted and getattr(self.client, '_budget_exhausted_use_free', False):
self.client._budget_exhausted_use_free = False
except Exception: # noqa: S110 — budget→free toggle is best-effort
pass
# Is the worker serving via the no-creds Spotify Free source this
# iteration? The daily budget and post-ban cooldown both exist to
# protect the REAL authenticated API from bans — they don't apply
# to free (a different, anonymous path). _free_active() now also
# returns True when the budget-bridge flag above is set. Computed
# once and reused below; the loop already probes auth, no extra cost.
try:
free_serving = self.client._free_active()
except Exception:
free_serving = False
# Daily budget guard — worker-only cap to avoid saturating the REAL
# Spotify API. Skipped while serving via free (free isn't that API).
if not free_serving and self._is_daily_budget_exhausted():
# Daily budget guard — pause ONLY when the budget is spent AND we
# can't serve via free (no free available). Otherwise free took over.
if not free_serving and budget_exhausted:
budget = self._get_daily_budget_info()
resets_in = budget['resets_in_seconds']
logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), "

View file

@ -60,6 +60,18 @@ def test_gate_open_when_no_auth_and_rate_limited():
assert should_use_free_fallback(authenticated=False, rate_limited=True) is True
def test_gate_open_when_budget_exhausted_even_if_authed_and_healthy():
# #758-follow-up: the real-API daily budget is spent, but the user has
# Spotify Free — switch to the uncapped free source instead of pausing.
assert should_use_free_fallback(authenticated=True, rate_limited=False,
budget_exhausted=True) is True
def test_gate_closed_when_authed_healthy_and_under_budget():
assert should_use_free_fallback(authenticated=True, rate_limited=False,
budget_exhausted=False) is False
# ---------------------------------------------------------------------------
# should_offer_spotify_metadata — the availability gate the callers use
# ---------------------------------------------------------------------------

View file

@ -461,14 +461,16 @@ function updateSpotifyEnrichmentStatusFromData(data) {
// Spotify Free source — treat it as running, not stuck (#798 bridge).
const bridgingFree = data.using_free === true;
const rateLimitedStuck = isRateLimited && !bridgingFree;
const budgetExhausted = data.daily_budget && data.daily_budget.exhausted;
// Budget is a real-API cap; when bridging to free it no longer applies, so
// only treat the budget as a stop when we're NOT serving via free (#798).
const budgetStuck = (data.daily_budget && data.daily_budget.exhausted) && !bridgingFree;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
if (data.paused) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (rateLimitedStuck || budgetExhausted) {
} else if (rateLimitedStuck || budgetStuck) {
button.classList.add('paused');
} else if (data.idle) {
button.classList.add('complete');
@ -485,7 +487,7 @@ function updateSpotifyEnrichmentStatusFromData(data) {
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (rateLimitedStuck) { tooltipStatus.textContent = 'Rate Limited'; }
else if (bridgingFree) { tooltipStatus.textContent = 'Running (Spotify Free)'; }
else if (budgetExhausted) { tooltipStatus.textContent = 'Daily Limit Reached'; }
else if (budgetStuck) { tooltipStatus.textContent = 'Daily Limit Reached'; }
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
else { tooltipStatus.textContent = 'Idle'; }
@ -502,7 +504,7 @@ function updateSpotifyEnrichmentStatusFromData(data) {
tooltipCurrent.textContent = remaining > 0 ? `Waiting ${Math.ceil(remaining / 60)}m for rate limit to clear` : 'Waiting for rate limit to clear';
} else if (bridgingFree && data.current_item && data.current_item.name) {
tooltipCurrent.textContent = `Now: ${data.current_item.name} (via Spotify Free)`;
} else if (budgetExhausted) {
} else if (budgetStuck) {
const resets = data.daily_budget.resets_in_seconds || 0;
const hours = Math.floor(resets / 3600);
const mins = Math.floor((resets % 3600) / 60);