feat(patreon): drift detection + error categorization — build step 4 (plan #697)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 28s
CI / integration (push) Successful in 2m58s

Typed, loud failure mapping for the native Patreon ingester so a changed
API shape or expired auth never silently zero-downloads as "success".

- New ErrorType.API_DRIFT (free varchar error_type col → no migration):
  distinct from auth so the operator knows the fix is updating the
  ingester, not rotating cookies.
- patreon_client: PatreonAPIError carries status_code; new PatreonAuthError
  for 401/403 + HTML-login/non-JSON bodies (reclassified from drift —
  expired-session is auth, actionable as "rotate cookies").
- patreon_ingester._failure_result maps: PatreonAuthError→AUTH_ERROR,
  PatreonDriftError→API_DRIFT ("Patreon API changed — ingester needs
  update"), HTTP 429→RATE_LIMITED, 404→NOT_FOUND, other HTTP→HTTP_ERROR,
  transport→NETWORK_ERROR. (429 thus drives the platform cooldown.)
- FailingSourcesCard: api_drift chip (red) + hint.

Contract test (new test_patreon_contract.py): the recorded /api/posts
fixture must parse end-to-end (no drift, 5 media across 4 posts) AND the
request params must still carry every field the parser depends on
(file_name, image_urls/download_url, images/attachments_media/media
includes, content/post_file/image post fields) — a trim of either trips a
red build. Plus client HTTP-status classification tests and ingester
error-type mapping tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:56:58 -04:00
parent 96c30eba13
commit 682beafbc5
7 changed files with 261 additions and 35 deletions
+54
View File
@@ -12,6 +12,8 @@ import pytest
from backend.app.services.patreon_client import (
MediaItem,
PatreonAPIError,
PatreonAuthError,
PatreonClient,
PatreonDriftError,
parse_cursor_from_url,
@@ -184,3 +186,55 @@ def test_media_referenced_but_absent_raises_drift(client):
}
with pytest.raises(PatreonDriftError):
client.extract_media(post, {})
# -- HTTP status classification (plan #697 step 4) -------------------------
class _FakeResp:
def __init__(self, status_code, *, json_data=None, raise_json=False):
self.status_code = status_code
self._json_data = json_data
self._raise_json = raise_json
def json(self):
if self._raise_json:
raise ValueError("Expecting value: line 1 column 1")
return self._json_data
def _client_returning(monkeypatch, resp):
client = PatreonClient(cookies_path=None)
monkeypatch.setattr(client._session, "get", lambda *a, **k: resp)
return client
@pytest.mark.parametrize("status", [401, 403])
def test_fetch_auth_status_raises_auth_error(monkeypatch, status):
client = _client_returning(monkeypatch, _FakeResp(status))
with pytest.raises(PatreonAuthError) as ei:
client._fetch("5555", None)
assert ei.value.status_code == status
def test_fetch_non_json_body_is_auth_not_drift(monkeypatch):
# An HTML login/challenge page (non-JSON) means the session expired — auth,
# actionable as "rotate cookies", NOT API drift.
client = _client_returning(monkeypatch, _FakeResp(200, raise_json=True))
with pytest.raises(PatreonAuthError):
client._fetch("5555", None)
@pytest.mark.parametrize("status", [404, 429, 500])
def test_fetch_other_status_raises_api_error_with_code(monkeypatch, status):
client = _client_returning(monkeypatch, _FakeResp(status))
with pytest.raises(PatreonAPIError) as ei:
client._fetch("5555", None)
assert ei.value.status_code == status
# Not auth — the ingester maps these by status, not to auth_error.
assert not isinstance(ei.value, PatreonAuthError)
def test_fetch_ok_returns_payload(monkeypatch):
client = _client_returning(monkeypatch, _FakeResp(200, json_data={"data": []}))
assert client._fetch("5555", None) == {"data": []}
+72
View File
@@ -0,0 +1,72 @@
"""Patreon API contract test (native ingester build step 4).
The drift tripwire the plan calls for: if the field-set we SEND or the parser we
resolve responses against drifts from what real Patreon returns, this build goes
RED — instead of the ingester silently importing nothing in production (the exact
failure mode the native ingester exists to escape).
Two halves:
- the recorded `/api/posts` fixture must still parse end-to-end (no drift), and
- the request params must still carry every field the parser depends on (the
fixture already has the data, so trimming the request would NOT trip the
parse half — this half guards the request shape directly).
Pure: no network, no DB.
"""
import json
from pathlib import Path
from backend.app.services.patreon_client import MediaItem, PatreonClient
_FIXTURE = Path(__file__).parent / "fixtures" / "patreon_posts_page1.json"
# Pinned media total across the 4 fixture posts: 1001→2 (gallery, image_large
# cover deduped), 1002→1 (attachment+postfile collapse), 1003→1 (inline content
# img), 1004→1 (video postfile). A parser change that drops or doubles media
# trips this.
_EXPECTED_MEDIA_TOTAL = 5
_KNOWN_KINDS = {"images", "image_large", "attachments", "postfile", "content"}
def test_recorded_fixture_parses_end_to_end():
response = json.loads(_FIXTURE.read_text())
client = PatreonClient(cookies_path=None)
client._validate_response(response) # top-level shape must hold
index = client._transform(response)
total = 0
for post in response["data"]:
items = client.extract_media(post, index) # must not raise drift
for m in items:
assert isinstance(m, MediaItem)
assert m.url, f"empty url in post {post['id']}"
assert m.filename, f"empty filename in post {post['id']}"
assert m.kind in _KNOWN_KINDS, f"unknown kind {m.kind!r}"
assert m.post_id == post["id"]
total += len(items)
assert total == _EXPECTED_MEDIA_TOTAL
def test_request_field_set_carries_parser_dependencies():
"""The params we SEND must keep requesting the fields the parser resolves
against. Trimming `fields[media]=file_name`, an `include` relationship, or a
`fields[post]` key would make real responses parse to nothing — caught here,
not by the fixture parse above."""
params = PatreonClient(cookies_path=None)._params("5555", None)
media_fields = params["fields[media]"]
assert "file_name" in media_fields
assert "image_urls" in media_fields or "download_url" in media_fields
includes = params["include"]
for rel in ("images", "attachments_media", "media"):
assert rel in includes, f"missing include relationship {rel}"
post_fields = params["fields[post]"]
for field in ("content", "post_file", "image"):
assert field in post_fields, f"missing post field {field}"
assert params["filter[campaign_id]"] == "5555"
+56 -8
View File
@@ -13,7 +13,12 @@ from sqlalchemy.orm import sessionmaker
from backend.app.models import Artist, PatreonSeenMedia, Source
from backend.app.services.gallery_dl import ErrorType
from backend.app.services.patreon_client import MediaItem, PatreonDriftError
from backend.app.services.patreon_client import (
MediaItem,
PatreonAPIError,
PatreonAuthError,
PatreonDriftError,
)
from backend.app.services.patreon_downloader import MediaOutcome
from backend.app.services.patreon_ingester import PatreonIngester, _ledger_key
@@ -302,20 +307,63 @@ async def test_mark_seen_is_idempotent(source_id, sync_engine, tmp_path):
assert _count_ledger(sync_engine, source_id) == 1
@pytest.mark.asyncio
async def test_drift_fails_loud(source_id, sync_engine, tmp_path):
client = _FakeClient([], raise_exc=PatreonDriftError("missing top-level 'data' key"))
downloader = _FakeDownloader(tmp_path)
ing = _ingester(sync_engine, tmp_path, client, downloader)
result = ing.run(
def _run_with_client_error(source_id, sync_engine, tmp_path, exc):
client = _FakeClient([], raise_exc=exc)
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
return ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick",
)
@pytest.mark.asyncio
async def test_drift_maps_to_api_drift_and_fails_loud(source_id, sync_engine, tmp_path):
result = _run_with_client_error(
source_id, sync_engine, tmp_path,
PatreonDriftError("missing top-level 'data' key"),
)
assert result.success is False
assert result.error_type == ErrorType.API_DRIFT
assert "Patreon API changed" in (result.error_message or "")
@pytest.mark.asyncio
async def test_auth_error_maps_to_auth_error(source_id, sync_engine, tmp_path):
result = _run_with_client_error(
source_id, sync_engine, tmp_path,
PatreonAuthError("HTTP 403 — auth rejected", status_code=403),
)
assert result.success is False
assert result.error_type == ErrorType.AUTH_ERROR
@pytest.mark.asyncio
async def test_rate_limit_status_maps_to_rate_limited(source_id, sync_engine, tmp_path):
result = _run_with_client_error(
source_id, sync_engine, tmp_path,
PatreonAPIError("HTTP 429", status_code=429),
)
assert result.error_type == ErrorType.RATE_LIMITED
@pytest.mark.asyncio
async def test_not_found_status_maps_to_not_found(source_id, sync_engine, tmp_path):
result = _run_with_client_error(
source_id, sync_engine, tmp_path,
PatreonAPIError("HTTP 404", status_code=404),
)
assert result.error_type == ErrorType.NOT_FOUND
@pytest.mark.asyncio
async def test_transport_error_maps_to_network_error(source_id, sync_engine, tmp_path):
result = _run_with_client_error(
source_id, sync_engine, tmp_path,
PatreonAPIError("connection reset"), # no status_code → transport
)
assert result.error_type == ErrorType.NETWORK_ERROR
def test_ledger_key_video_has_no_filehash():
video = MediaItem(
url="https://stream.mux.com/abc.m3u8", filename="clip.mp4",