diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index 9d7c9b1..a1312ba 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -5,7 +5,7 @@ from sqlalchemy import select from ..extensions import get_session from ..models import DownloadEvent, Source -from ..services.scheduler_service import scheduler_status +from ..services.scheduler_service import active_platform_cooldowns, scheduler_status from ..services.source_service import ( KNOWN_PLATFORMS, ArtistNotFoundError, @@ -125,7 +125,16 @@ async def check_source(source_id: int): """FC-3c: enqueue a download for this source. Returns 202 with the new DownloadEvent id. If a pending/running - event already exists for this source, returns 409 with that id.""" + event already exists for this source, returns 409 with that id. If + the source's platform is currently in a rate-limit cooldown, returns + **202 with `{status: "deferred", cooldown_until, platform}`** and + does NOT create an event or dispatch — the bulk retry path uses this + to avoid bowling N sources right back into the rate limit the + cooldown is preventing. Single-click "retry this one source" passes + `?force=true` to override the cooldown (operator-explicit, useful + for rapid auth-fix testing). The in-flight guard always applies. + """ + force = (request.args.get("force") or "").lower() in ("1", "true", "yes") async with get_session() as session: source = (await session.execute( select(Source).where(Source.id == source_id) @@ -135,6 +144,19 @@ async def check_source(source_id: int): if not source.enabled: return _bad("source_disabled", detail="enable the source first") + # Cooldown gate (unless explicitly overridden). Checked before the + # in-flight guard because a deferred retry doesn't need to create + # or check for an event at all. + if not force: + cooldowns = await active_platform_cooldowns(session) + expires_at = cooldowns.get(source.platform) + if expires_at is not None: + return jsonify({ + "status": "deferred", + "platform": source.platform, + "cooldown_until": expires_at.isoformat(), + }), 202 + in_flight = (await session.execute( select(DownloadEvent.id).where( DownloadEvent.source_id == source_id, diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py index dd038a4..adda050 100644 --- a/backend/app/services/scheduler_service.py +++ b/backend/app/services/scheduler_service.py @@ -87,10 +87,15 @@ async def set_platform_cooldown( await session.execute(stmt) -async def _platforms_in_cooldown(session: AsyncSession) -> dict[str, datetime]: +async def active_platform_cooldowns(session: AsyncSession) -> dict[str, datetime]: """Return {platform: expires_at} for platforms whose cooldown is still in the future. Expired rows are ignored (a future maintenance sweep can - delete them; they don't affect routing decisions on their own).""" + delete them; they don't affect routing decisions on their own). + + Exposed beyond scheduler_service so the manual check endpoint + (`/api/sources//check`) can defer bulk retries that would bowl + into the same rate limit the cooldown is preventing. + """ rows = (await session.execute( select(AppSetting.key, AppSetting.value) .where(AppSetting.key.startswith(PLATFORM_COOLDOWN_KEY_PREFIX)) @@ -133,7 +138,7 @@ async def select_due_sources(session: AsyncSession) -> list[Source]: .order_by(Source.last_checked_at.asc().nulls_first(), Source.id) )).scalars().all() - cooldowns = await _platforms_in_cooldown(session) + cooldowns = await active_platform_cooldowns(session) settings = await ImportSettings.load(session) now = datetime.now(UTC) @@ -212,7 +217,7 @@ async def scheduler_status(session: AsyncSession) -> dict: elif next_due_at is None or nca < next_due_at: next_due_at = nca - cooldowns = await _platforms_in_cooldown(session) + cooldowns = await active_platform_cooldowns(session) return { "last_tick_at": last_tick_at, diff --git a/frontend/src/components/subscriptions/DownloadsTab.vue b/frontend/src/components/subscriptions/DownloadsTab.vue index 0307c63..9a48144 100644 --- a/frontend/src/components/subscriptions/DownloadsTab.vue +++ b/frontend/src/components/subscriptions/DownloadsTab.vue @@ -171,10 +171,12 @@ async function refresh() { } // Retry a single failing source (re-runs its whole feed) then refresh the -// rollup + stats so the operator sees it move. +// rollup + stats so the operator sees it move. Passes force=true so the +// platform cooldown is bypassed — single-source click is an explicit +// operator override, useful for rapid auth-fix or fixture testing. async function onRetrySource(source) { try { - await sourcesStore.checkNow(source.id) + await sourcesStore.checkNow(source.id, { force: true }) toast({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' }) } catch (e) { if (e?.body?.download_event_id) { @@ -187,15 +189,24 @@ async function onRetrySource(source) { } } +// Bulk retry — leaves cooldown enforcement ON so N failing sources on +// the same platform don't all retry into the rate limit the cooldown is +// preventing. Sources deferred by cooldown will be picked up by the +// next scan tick after the AppSetting expires. Toast tallies the three +// outcomes so the operator can quickly read whether cooldown is the +// dominant failure mode ("12 deferred (cooldown)" → yes, rate limit is +// the issue). async function onRetryAll(sources) { retryingAll.value = true let ok = 0 let conflict = 0 + let deferred = 0 try { for (const s of sources) { try { - await sourcesStore.checkNow(s.id) - ok += 1 + const body = await sourcesStore.checkNow(s.id) + if (body?.status === 'deferred') deferred += 1 + else ok += 1 } catch (e) { if (e?.body?.download_event_id) conflict += 1 } @@ -206,6 +217,7 @@ async function onRetryAll(sources) { } const parts = [] if (ok) parts.push(`${ok} queued`) + if (deferred) parts.push(`${deferred} deferred (cooldown)`) if (conflict) parts.push(`${conflict} already running`) toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' }) } diff --git a/frontend/src/stores/sources.js b/frontend/src/stores/sources.js index 77ece67..a04c5a0 100644 --- a/frontend/src/stores/sources.js +++ b/frontend/src/stores/sources.js @@ -67,10 +67,17 @@ export const useSourcesStore = defineStore('sources', () => { // FC-3c: trigger a download for one source. Returns {download_event_id,status}. const checkingIds = ref(new Set()) - async function checkNow(id) { + // force=true bypasses the platform-rate-limit cooldown gate. Single- + // source RETRY clicks pass it (operator-explicit override, useful for + // rapid auth-fix testing); bulk RETRY ALL / MaintenanceMenu retries + // leave it off so the cooldown does its preventive job. + async function checkNow(id, { force = false } = {}) { checkingIds.value = new Set(checkingIds.value).add(id) try { - return await api.post(`/api/sources/${id}/check`) + const url = force + ? `/api/sources/${id}/check?force=true` + : `/api/sources/${id}/check` + return await api.post(url) } finally { const next = new Set(checkingIds.value) next.delete(id) diff --git a/tests/test_api_sources_check.py b/tests/test_api_sources_check.py index 8b2cfa6..2b201b5 100644 --- a/tests/test_api_sources_check.py +++ b/tests/test_api_sources_check.py @@ -63,3 +63,55 @@ async def test_check_409_when_already_running(client, db, seed, monkeypatch): assert resp.status_code == 409 body = await resp.get_json() assert body["download_event_id"] == existing.id + + +@pytest.mark.asyncio +async def test_check_defers_when_platform_in_cooldown(client, db, seed, monkeypatch): + """Bulk retries land here without ?force — when the source's platform + has an active cooldown, return 202 with status='deferred' instead of + creating an event. Lets the bulk path tally deferred-vs-queued in + the toast so the operator can see rate-limit-induced failures at a + glance.""" + from backend.app.services.scheduler_service import set_platform_cooldown + + delays: list = [] + monkeypatch.setattr( + "backend.app.tasks.download.download_source.delay", + lambda *a, **k: delays.append(a), + ) + + await set_platform_cooldown(db, seed.platform, seconds=900) + await db.commit() + + resp = await client.post(f"/api/sources/{seed.id}/check") + assert resp.status_code == 202 + body = await resp.get_json() + assert body["status"] == "deferred" + assert body["platform"] == seed.platform + assert body["cooldown_until"] + assert delays == [] # no dispatch when deferred + + +@pytest.mark.asyncio +async def test_check_force_overrides_cooldown(client, db, seed, monkeypatch): + """Single-source RETRY click sends ?force=true — explicit operator + override, used for rapid auth-fix testing without waiting on the + cooldown.""" + from backend.app.services.scheduler_service import set_platform_cooldown + + delays: list = [] + monkeypatch.setattr( + "backend.app.tasks.download.download_source.delay", + lambda *a, **k: delays.append(a), + ) + + await set_platform_cooldown(db, seed.platform, seconds=900) + await db.commit() + + resp = await client.post(f"/api/sources/{seed.id}/check?force=true") + assert resp.status_code == 202 + body = await resp.get_json() + assert body["status"] == "pending" + assert isinstance(body["download_event_id"], int) + assert len(delays) == 1 + assert delays[0] == (seed.id,)