feat(downloads): platform cooldown honors server Retry-After — B1 (plan #708)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m1s

Owning the native client means we see the 429 Retry-After header — previously
discarded. PatreonAPIError now carries `retry_after`; on a PERSISTENT page-fetch
429 the client attaches the server's raw Retry-After seconds. New
DownloadResult.retry_after_seconds; patreon_ingester._failure_result sets it on
RATE_LIMITED. download_service._update_source_health passes it to
set_platform_cooldown as `seconds=`, clamped to [60, 3600] (a tiny hint can't
leave the platform effectively un-cooled; a huge one can't strand it for hours);
no hint → the flat 900s default. So a rate-limited platform cools for as long as
the server actually asks, not a fixed guess.

Tests: terminal 429 surfaces retry_after (test_patreon_client); cooldown honors
+ clamps the hint, falls back to default when absent (test_download_service).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 11:34:37 -04:00
parent e47fa0cf4b
commit fb7383eea7
6 changed files with 91 additions and 4 deletions
+34
View File
@@ -437,6 +437,40 @@ async def test_finalize_error_increments_failures_and_sets_error(db):
assert row.last_checked_at is not None
@pytest.mark.asyncio
async def test_rate_limited_cooldown_honors_retry_after(db, monkeypatch):
"""plan #708 B1: a RATE_LIMITED result with a Retry-After stamps the platform
cooldown at that duration, clamped to [60, 3600]; no hint → the flat default."""
from unittest.mock import AsyncMock, MagicMock
from backend.app.services import download_service as dl_mod
from backend.app.services.download_service import DownloadService
source_id, _event_id = await _seed_source_with_health(db, suffix="rl")
cooldown = AsyncMock()
monkeypatch.setattr(dl_mod, "set_platform_cooldown", cooldown)
svc = DownloadService(
async_session=db, sync_session=None,
gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
)
async def _health(retry):
cooldown.reset_mock()
await svc._update_source_health(
source_id=source_id, status="error", error_message="rate limited",
error_type="rate_limited", retry_after_seconds=retry,
)
await _health(120.0)
assert cooldown.await_args.kwargs["seconds"] == 120 # honored as-is
await _health(5.0)
assert cooldown.await_args.kwargs["seconds"] == 60 # floored
await _health(99999.0)
assert cooldown.await_args.kwargs["seconds"] == 3600 # capped
await _health(None)
assert "seconds" not in cooldown.await_args.kwargs # default cooldown
@pytest.mark.asyncio
async def test_finalize_skipped_preserves_failures_clears_error(db):
from backend.app.services.download_service import DownloadService
+12
View File
@@ -327,6 +327,18 @@ def test_fetch_persistent_429_raises_after_retries(monkeypatch):
assert not isinstance(ei.value, PatreonAuthError)
def test_fetch_persistent_429_surfaces_retry_after(monkeypatch):
"""plan #708 B1: a terminal 429 carries the server's raw Retry-After seconds
(uncapped here — the cooldown clamps) so the platform cooldown matches it."""
client, _slept = _client_with_sequence(
monkeypatch, [_FakeResp(429, headers={"Retry-After": "120"})] * 10,
)
with pytest.raises(PatreonAPIError) as ei:
client._fetch("5555", None)
assert ei.value.status_code == 429
assert ei.value.retry_after == 120.0
def test_request_sleep_paces_before_fetch(monkeypatch):
client = PatreonClient(cookies_path=None, request_sleep=1.5)
monkeypatch.setattr(