feat(patreon): ingester rate-limit resilience — #703 step 1
CI / frontend-build (push) Successful in 21s
CI / integration (push) Failing after 3m0s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s

A single 429 mid-walk used to fail the run → RATE_LIMITED → platform-wide
cooldown → every Patreon source dark ("testing dead"). The native path also
ignored the operator's existing politeness setting. Fixed both:

- Pacing (avoid 429s): honor download_rate_limit_seconds (gallery-dl's
  `rate_limit`, read off self.gdl) as a pre-download sleep on real media
  downloads only (skips don't pace); pace /api/posts page fetches with the
  per-source sleep_request override, defaulting to max(0.5, rate_limit/4) —
  the same API-pacing default gallery-dl used for `sleep-request`.
- 429 backoff (ride out transient limits): PatreonClient._fetch retries a
  429 with backoff (honor Retry-After, else exponential 2·2^(n-1), capped
  30s, ≤3 tries); only a PERSISTENT 429 propagates as terminal
  RATE_LIMITED. Light 2-retry on a media-GET 429 too.

Threaded via PatreonIngester(rate_limit=, request_sleep=) →
PatreonClient/PatreonDownloader; download_service sources them. Injected
test client/downloader are unaffected (carry their own pacing).

Tests mock time.sleep (no real sleeping): retry-then-success, persistent
429 raises after N, Retry-After honored, request_sleep paces, media pacing
per real download, skips don't pace, media 429 retried.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:13:46 -04:00
parent 218bfebb92
commit 3b2f7a41c3
6 changed files with 254 additions and 18 deletions
+15
View File
@@ -207,11 +207,26 @@ class DownloadService:
None, None,
) )
# Honor the operator's existing rate-limit knobs on the native path
# (plan #703): the global download_rate_limit_seconds (gallery-dl's
# `rate_limit`, here on self.gdl) paces media downloads. Page fetches use
# the per-source sleep_request override, else `max(0.5, rate_limit/4)` —
# the SAME API-pacing default gallery-dl applied as its `sleep-request`
# (gallery_dl._get_default_config), so the rate-limited /api/posts
# endpoint stays politely paced without over-throttling.
rate_limit = self.gdl._rate_limit
request_sleep = (
source_config.sleep_request
if source_config.sleep_request is not None
else max(0.5, rate_limit / 4)
)
ingester = PatreonIngester( ingester = PatreonIngester(
images_root=self.gdl.images_root, images_root=self.gdl.images_root,
cookies_path=ctx["cookies_path"], cookies_path=ctx["cookies_path"],
session_factory=self.sync_session_factory, session_factory=self.sync_session_factory,
validate=self.gdl._validate_files, validate=self.gdl._validate_files,
rate_limit=rate_limit,
request_sleep=request_sleep,
) )
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
dl_result = await loop.run_in_executor( dl_result = await loop.run_in_executor(
+68 -11
View File
@@ -30,6 +30,7 @@ import http.cookiejar
import logging import logging
import os import os
import re import re
import time
from collections.abc import Iterator from collections.abc import Iterator
from dataclasses import dataclass from dataclasses import dataclass
from html import unescape from html import unescape
@@ -47,6 +48,34 @@ _USER_AGENT = (
) )
_TIMEOUT_SECONDS = 30.0 _TIMEOUT_SECONDS = 30.0
# 429 backoff (plan #703): ride out a transient API rate-limit instead of
# failing the whole walk (which would stamp RATE_LIMITED → platform-wide
# cooldown → every Patreon source dark). Honor the server's `Retry-After`;
# otherwise exponential base·2^(n-1), capped. Only after the retries are
# exhausted does the 429 propagate as terminal RATE_LIMITED.
_MAX_429_RETRIES = 3
_BACKOFF_BASE_SECONDS = 2.0
_BACKOFF_CAP_SECONDS = 30.0
def _retry_after_seconds(
resp: requests.Response,
attempt: int,
*,
base: float = _BACKOFF_BASE_SECONDS,
cap: float = _BACKOFF_CAP_SECONDS,
) -> float:
"""Backoff delay for a 429: the `Retry-After` seconds header if present and
numeric, else exponential `base·2^(attempt-1)`, both capped. (HTTP-date form
of Retry-After is rare here and falls through to exponential.)"""
header = resp.headers.get("Retry-After")
if header:
try:
return min(float(header), cap)
except (TypeError, ValueError):
pass
return min(base * (2 ** max(0, attempt - 1)), cap)
# JSON:API request contract (observed from real traffic — see module plan). # JSON:API request contract (observed from real traffic — see module plan).
_INCLUDE = ( _INCLUDE = (
"campaign,access_rules,attachments,attachments_media,audio,images,media," "campaign,access_rules,attachments,attachments_media,audio,images,media,"
@@ -205,9 +234,19 @@ class PatreonClient:
requests.Session; no secure-context APIs are used. requests.Session; no secure-context APIs are used.
""" """
def __init__(self, cookies_path: str | Path | None): def __init__(
self,
cookies_path: str | Path | None,
*,
request_sleep: float = 0.0,
max_retries: int = _MAX_429_RETRIES,
):
self.cookies_path = str(cookies_path) if cookies_path else None self.cookies_path = str(cookies_path) if cookies_path else None
self._session = _load_session(cookies_path) self._session = _load_session(cookies_path)
# Politeness: seconds to sleep before each /api/posts page fetch (paces
# the rate-limited API endpoint). 0 = no pacing. plan #703.
self._request_sleep = request_sleep or 0.0
self._max_retries = max_retries
# -- request ----------------------------------------------------------- # -- request -----------------------------------------------------------
@@ -228,16 +267,34 @@ class PatreonClient:
return params return params
def _fetch(self, campaign_id: str, cursor: str | None) -> dict: def _fetch(self, campaign_id: str, cursor: str | None) -> dict:
try: if self._request_sleep > 0:
resp = self._session.get( time.sleep(self._request_sleep) # pace the API endpoint
_POSTS_URL, attempt = 0
params=self._params(campaign_id, cursor), while True:
timeout=_TIMEOUT_SECONDS, try:
) resp = self._session.get(
except requests.RequestException as exc: _POSTS_URL,
raise PatreonAPIError( params=self._params(campaign_id, cursor),
f"Patreon posts request failed (campaign_id={campaign_id}): {exc}" timeout=_TIMEOUT_SECONDS,
) from exc )
except requests.RequestException as exc:
raise PatreonAPIError(
f"Patreon posts request failed (campaign_id={campaign_id}): {exc}"
) from exc
# Transient rate-limit: back off and retry rather than failing the
# whole walk. Only a PERSISTENT 429 (retries exhausted) falls
# through to the terminal RATE_LIMITED raise below.
if resp.status_code == 429 and attempt < self._max_retries:
attempt += 1
delay = _retry_after_seconds(resp, attempt)
log.warning(
"Patreon 429 (campaign_id=%s) — backing off %.1fs (retry %d/%d)",
campaign_id, delay, attempt, self._max_retries,
)
time.sleep(delay)
continue
break
if resp.status_code in (401, 403): if resp.status_code in (401, 403):
# Auth rejected — expired/missing cookies or an insufficient tier. # Auth rejected — expired/missing cookies or an insufficient tier.
+34 -3
View File
@@ -33,6 +33,7 @@ import logging
import os import os
import shutil import shutil
import subprocess import subprocess
import time
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -42,13 +43,16 @@ from urllib.parse import urlsplit
import requests import requests
from .file_validator import is_validatable, validate_file from .file_validator import is_validatable, validate_file
from .patreon_client import _load_session from .patreon_client import _load_session, _retry_after_seconds
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_TITLE_MAX = 40 _TITLE_MAX = 40
_TIMEOUT_SECONDS = 120.0 _TIMEOUT_SECONDS = 120.0
_CHUNK = 1 << 16 _CHUNK = 1 << 16
# A CDN media GET rarely 429s, but if it does, a couple of backoff retries keep
# one throttled file from becoming a per-item error (plan #703).
_MAX_MEDIA_429_RETRIES = 2
# Referer/Origin yt-dlp must send for Mux-hosted Patreon video. Mux's JWT # Referer/Origin yt-dlp must send for Mux-hosted Patreon video. Mux's JWT
# playback policy checks Referer/Origin on every request, so yt-dlp must send # playback policy checks Referer/Origin on every request, so yt-dlp must send
@@ -148,11 +152,17 @@ class PatreonDownloader:
cookies_path: str | None = None, cookies_path: str | None = None,
*, *,
validate: bool = True, validate: bool = True,
rate_limit: float = 0.0,
session: requests.Session | None = None, session: requests.Session | None = None,
): ):
self.images_root = Path(images_root) self.images_root = Path(images_root)
self.cookies_path = str(cookies_path) if cookies_path else None self.cookies_path = str(cookies_path) if cookies_path else None
self._validate = validate self._validate = validate
# Politeness: seconds to sleep before each actual media download (paces
# the CDN; honors ImportSettings.download_rate_limit_seconds, the same
# value gallery-dl used as its between-downloads `sleep`). 0 = no pacing.
# Applied only to real downloads, not to seen/disk skips. plan #703.
self._rate_limit = rate_limit or 0.0
# Build a cookie-loaded session the same way patreon_client does, so the # Build a cookie-loaded session the same way patreon_client does, so the
# CDN GETs carry the creator's auth. # CDN GETs carry the creator's auth.
self.session = session if session is not None else _load_session(cookies_path) self.session = session if session is not None else _load_session(cookies_path)
@@ -227,6 +237,10 @@ class PatreonDownloader:
post_dir.mkdir(parents=True, exist_ok=True) post_dir.mkdir(parents=True, exist_ok=True)
# Pace real downloads only (the skips above already returned). plan #703.
if self._rate_limit > 0:
time.sleep(self._rate_limit)
if _is_video_url(media.url): if _is_video_url(media.url):
out_path = self._run_ytdlp(media.url, media_path, _VIDEO_HEADERS) out_path = self._run_ytdlp(media.url, media_path, _VIDEO_HEADERS)
if out_path is None or not Path(out_path).exists(): if out_path is None or not Path(out_path).exists():
@@ -267,8 +281,25 @@ class PatreonDownloader:
return dest return dest
def _fetch_to_file(self, url: str, dest: Path) -> None: def _fetch_to_file(self, url: str, dest: Path) -> None:
"""Stream a non-video URL to `dest` via the (stubbable) session.""" """Stream a non-video URL to `dest` via the (stubbable) session.
resp = self.session.get(url, stream=True, timeout=_TIMEOUT_SECONDS)
Retries a transient 429 with backoff (plan #703); any other non-2xx
falls through to raise_for_status → HTTPError, which download_post turns
into a resilient per-item error outcome.
"""
attempt = 0
while True:
resp = self.session.get(url, stream=True, timeout=_TIMEOUT_SECONDS)
if resp.status_code == 429 and attempt < _MAX_MEDIA_429_RETRIES:
attempt += 1
delay = _retry_after_seconds(resp, attempt)
log.warning(
"Patreon media 429 (%s) — backing off %.1fs (retry %d/%d)",
url, delay, attempt, _MAX_MEDIA_429_RETRIES,
)
time.sleep(delay)
continue
break
resp.raise_for_status() resp.raise_for_status()
with open(dest, "wb") as fh: with open(dest, "wb") as fh:
for chunk in resp.iter_content(chunk_size=_CHUNK): for chunk in resp.iter_content(chunk_size=_CHUNK):
+12 -2
View File
@@ -104,18 +104,28 @@ class PatreonIngester:
session_factory: Callable[[], object], session_factory: Callable[[], object],
*, *,
validate: bool = True, validate: bool = True,
rate_limit: float = 0.0,
request_sleep: float = 0.0,
client: PatreonClient | None = None, client: PatreonClient | None = None,
downloader: PatreonDownloader | None = None, downloader: PatreonDownloader | None = None,
): ):
self.images_root = Path(images_root) self.images_root = Path(images_root)
self.cookies_path = str(cookies_path) if cookies_path else None self.cookies_path = str(cookies_path) if cookies_path else None
self.session_factory = session_factory self.session_factory = session_factory
self.client = client if client is not None else PatreonClient(cookies_path) # Pacing (plan #703): request_sleep paces the API page fetches,
# rate_limit paces the media downloads. Injected client/downloader (in
# tests) already carry their own pacing, so these only apply to the
# default-constructed ones.
self.client = (
client
if client is not None
else PatreonClient(cookies_path, request_sleep=request_sleep)
)
self.downloader = ( self.downloader = (
downloader downloader
if downloader is not None if downloader is not None
else PatreonDownloader( else PatreonDownloader(
self.images_root, cookies_path, validate=validate self.images_root, cookies_path, validate=validate, rate_limit=rate_limit
) )
) )
+63 -1
View File
@@ -10,12 +10,14 @@ from pathlib import Path
import pytest import pytest
from backend.app.services import patreon_client as pc_mod
from backend.app.services.patreon_client import ( from backend.app.services.patreon_client import (
MediaItem, MediaItem,
PatreonAPIError, PatreonAPIError,
PatreonAuthError, PatreonAuthError,
PatreonClient, PatreonClient,
PatreonDriftError, PatreonDriftError,
_retry_after_seconds,
parse_cursor_from_url, parse_cursor_from_url,
) )
@@ -192,10 +194,11 @@ def test_media_referenced_but_absent_raises_drift(client):
class _FakeResp: class _FakeResp:
def __init__(self, status_code, *, json_data=None, raise_json=False): def __init__(self, status_code, *, json_data=None, raise_json=False, headers=None):
self.status_code = status_code self.status_code = status_code
self._json_data = json_data self._json_data = json_data
self._raise_json = raise_json self._raise_json = raise_json
self.headers = headers or {}
def json(self): def json(self):
if self._raise_json: if self._raise_json:
@@ -275,3 +278,62 @@ def test_verify_auth_inconclusive_on_network(monkeypatch):
monkeypatch.setattr(client._session, "get", _raise) monkeypatch.setattr(client._session, "get", _raise)
ok, _msg = client.verify_auth("5555") ok, _msg = client.verify_auth("5555")
assert ok is None assert ok is None
# -- 429 backoff + pacing (plan #703) --------------------------------------
def test_retry_after_seconds_honors_numeric_header():
assert _retry_after_seconds(_FakeResp(429, headers={"Retry-After": "7"}), 1) == 7.0
def test_retry_after_seconds_exponential_without_header():
resp = _FakeResp(429)
assert _retry_after_seconds(resp, 1) == 2.0
assert _retry_after_seconds(resp, 2) == 4.0
assert _retry_after_seconds(resp, 3) == 8.0
def test_retry_after_seconds_caps():
assert _retry_after_seconds(_FakeResp(429), 10) == 30.0
assert _retry_after_seconds(_FakeResp(429, headers={"Retry-After": "999"}), 1) == 30.0
def _client_with_sequence(monkeypatch, responses):
"""A client whose session yields `responses` in order; time.sleep stubbed so
backoff never really sleeps. Returns (client, slept_list)."""
client = PatreonClient(cookies_path=None)
it = iter(responses)
monkeypatch.setattr(client._session, "get", lambda *a, **k: next(it))
slept: list[float] = []
monkeypatch.setattr(pc_mod.time, "sleep", lambda s: slept.append(s))
return client, slept
def test_fetch_retries_then_succeeds_on_429(monkeypatch):
client, slept = _client_with_sequence(monkeypatch, [
_FakeResp(429, headers={"Retry-After": "5"}),
_FakeResp(200, json_data={"data": []}),
])
assert client._fetch("5555", None) == {"data": []}
assert 5.0 in slept # backed off once before the retry
def test_fetch_persistent_429_raises_after_retries(monkeypatch):
client, _slept = _client_with_sequence(monkeypatch, [_FakeResp(429)] * 10)
with pytest.raises(PatreonAPIError) as ei:
client._fetch("5555", None)
assert ei.value.status_code == 429
assert not isinstance(ei.value, PatreonAuthError)
def test_request_sleep_paces_before_fetch(monkeypatch):
client = PatreonClient(cookies_path=None, request_sleep=1.5)
monkeypatch.setattr(
client._session, "get",
lambda *a, **k: _FakeResp(200, json_data={"data": []}),
)
slept: list[float] = []
monkeypatch.setattr(pc_mod.time, "sleep", lambda s: slept.append(s))
client._fetch("5555", None)
assert slept == [1.5]
+62 -1
View File
@@ -10,7 +10,9 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest import pytest
import requests
from backend.app.services import patreon_downloader as pd_mod
from backend.app.services.patreon_client import MediaItem from backend.app.services.patreon_client import MediaItem
from backend.app.services.patreon_downloader import ( from backend.app.services.patreon_downloader import (
MediaOutcome, MediaOutcome,
@@ -26,10 +28,14 @@ _PNG_BYTES = _PNG_HEAD + (b"\x00" * 16) + _PNG_TAIL
class _FakeResponse: class _FakeResponse:
def __init__(self, payload: bytes): def __init__(self, payload: bytes, status_code: int = 200, headers=None):
self._payload = payload self._payload = payload
self.status_code = status_code
self.headers = headers or {}
def raise_for_status(self): def raise_for_status(self):
if self.status_code >= 400:
raise requests.HTTPError(f"HTTP {self.status_code}")
return None return None
def iter_content(self, chunk_size=65536): def iter_content(self, chunk_size=65536):
@@ -49,6 +55,18 @@ class _FakeSession:
return _FakeResponse(self.payloads.get(url, _PNG_BYTES)) return _FakeResponse(self.payloads.get(url, _PNG_BYTES))
class _SeqSession:
"""Returns the queued responses in order (for retry tests)."""
def __init__(self, responses: list[_FakeResponse]):
self._responses = list(responses)
self.calls: list[str] = []
def get(self, url, stream=False, timeout=None):
self.calls.append(url)
return self._responses.pop(0)
def _post(post_id="1001", title="My Post Title", published="2026-05-01T12:00:00Z"): def _post(post_id="1001", title="My Post Title", published="2026-05-01T12:00:00Z"):
return { return {
"id": post_id, "id": post_id,
@@ -250,3 +268,46 @@ def test_media_outcome_shape():
o = MediaOutcome(media=None, status="downloaded", path=Path("/tmp/x"), error=None) o = MediaOutcome(media=None, status="downloaded", path=Path("/tmp/x"), error=None)
assert o.status == "downloaded" assert o.status == "downloaded"
assert o.path == Path("/tmp/x") assert o.path == Path("/tmp/x")
# -- pacing + 429 backoff (plan #703) --------------------------------------
def test_rate_limit_paces_each_real_download(tmp_path, monkeypatch):
slept: list[float] = []
monkeypatch.setattr(pd_mod.time, "sleep", lambda s: slept.append(s))
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False,
rate_limit=2.0, session=_FakeSession(),
)
dl.download_post(_post(), [_img("a.png"), _img("b.png")], "artist-x")
# One pacing sleep per actual download (two media downloaded).
assert slept == [2.0, 2.0]
def test_skips_do_not_pace(tmp_path, monkeypatch):
slept: list[float] = []
monkeypatch.setattr(pd_mod.time, "sleep", lambda s: slept.append(s))
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False,
rate_limit=2.0, session=_FakeSession(),
)
# is_seen → skipped_seen, never reaches the download/pacing path.
dl.download_post(_post(), [_img("a.png")], "artist-x", is_seen=lambda m: True)
assert slept == []
def test_media_429_retried_then_succeeds(tmp_path, monkeypatch):
slept: list[float] = []
monkeypatch.setattr(pd_mod.time, "sleep", lambda s: slept.append(s))
session = _SeqSession([
_FakeResponse(b"", status_code=429, headers={"Retry-After": "4"}),
_FakeResponse(_PNG_BYTES, status_code=200),
])
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False, session=session,
)
outcomes = dl.download_post(_post(), [_img("a.png")], "artist-x")
assert outcomes[0].status == "downloaded"
assert 4.0 in slept # backed off before the retry
assert len(session.calls) == 2