diff --git a/app/connectors/clips/redgifs.py b/app/connectors/clips/redgifs.py index 227c431..2027b61 100644 --- a/app/connectors/clips/redgifs.py +++ b/app/connectors/clips/redgifs.py @@ -17,7 +17,10 @@ globalne „najnowsze" (98-100 na stronę). `order` ∈ latest|top|top7|top28|tr NIE `new` (400 BadOrder). Każde zapytanie widzi maks. 10 000 wyników (`pages` ≤ 100, strona 101 → 500), więc pełny katalog chodzi się po niszach (`/v2/niches`, 1812 społeczności, `/v2/niches//gifs`) i po twórcach (`/v2/users//search`). -Brak nagłówków rate-limit; trzymamy ~3 zapytania/s i ustępujemy przy 429. +Limit: per IP i per endpoint (`subject: "|niche-gifs|"` w ciele 429), bez nagłówka +Retry-After, za to w ciele `error.delay` w sekundach (widziane 1167 s po ~65 zapytaniach +w 20 s). Trzymamy 1 zapytanie/s; przy 429 czekamy `delay`, a gdy jest dłuższy niż 5 min, +przerywamy przebieg (`RateLimited`), bo dalsze zapytania tylko przedłużają blokadę. **Rekord gifa** (pola, które bierzemy): `id`, `type` (1 = wideo, 2 = obraz, pomijamy), `urls{hd,sd,poster,thumbnail}`, `duration`, `width/height`, `hasAudio`, `hls`, `tags[]`, @@ -48,7 +51,12 @@ _HEADERS = { "Referer": "https://www.redgifs.com/", } _PAGE_MAX = 100 -_MIN_INTERVAL_S = 0.35 +#: Pomiar 2026-09-14: przy ~3 zapytaniach/s po ~65 zapytaniach z rzedu RedGifs oddaje 429 +#: z `delay` ~20 min. 1 zapytanie/s ma sie trzymac w limicie (do weryfikacji na backfillu). +_MIN_INTERVAL_S = 1.0 +_RATE_LIMIT_BACKOFF_S = (30, 90, 180) +#: Dluzsze `delay` z ciala 429 nie czekamy w procesie, tylko przerywamy przebieg. +_RATE_LIMIT_MAX_WAIT_S = 300 _TAG_SLUG_RE = re.compile(r"[^a-z0-9]+") @@ -128,8 +136,27 @@ def map_gif(g: dict) -> RawClip | None: ) +def _rate_limit_wait(r: httpx.Response, attempt: int) -> float: + """Odstęp po 429: `Retry-After`, potem `error.delay` z ciała, potem stała drabinka.""" + retry_after = r.headers.get("Retry-After") + if retry_after and retry_after.isdigit(): + return float(retry_after) + try: + delay = (r.json() or {}).get("error", {}).get("delay") + if isinstance(delay, (int, float)) and delay > 0: + return float(delay) + except ValueError: + pass + return float(_RATE_LIMIT_BACKOFF_S[min(attempt, len(_RATE_LIMIT_BACKOFF_S) - 1)]) + + +class RateLimited(RuntimeError): + """429 mimo odczekania: wołający ma przerwać przebieg, nie iść do następnej niszy + (każde kolejne zapytanie tylko przedłuża blokadę).""" + + class RedgifsClient: - """Cienki klient z tokenem tymczasowym i odnowieniem na 401.""" + """Cienki klient z tokenem tymczasowym, odnowieniem na 401 i odczekiwaniem 429.""" def __init__(self, *, timeout: float = 30.0) -> None: self._http = httpx.Client(headers=_HEADERS, timeout=timeout) @@ -153,18 +180,24 @@ class RedgifsClient: if wait > 0: time.sleep(wait) tok = self._token or self._auth() - for attempt in range(3): + for attempt in range(1 + len(_RATE_LIMIT_BACKOFF_S)): r = self._http.get(f"{_BASE}{path}", params=params, headers={"Authorization": f"Bearer {tok}"}) self._last_call = time.monotonic() if r.status_code == 401 and attempt == 0: tok = self._auth() continue if r.status_code == 429: - time.sleep(5 * (attempt + 1)) + if attempt >= len(_RATE_LIMIT_BACKOFF_S): + raise RateLimited(f"redgifs: 429 na {path} po {attempt} odczekaniach") + wait = _rate_limit_wait(r, attempt) + if wait > _RATE_LIMIT_MAX_WAIT_S: + raise RateLimited(f"redgifs: 429 na {path}, zadany odstep {wait:.0f} s") + log.info("redgifs: 429 na %s, czekam %.0f s", path, wait) + time.sleep(wait) continue r.raise_for_status() return r.json() - raise RuntimeError(f"redgifs: {path} nie odpowiedzial poprawnie po 3 probach") + raise RuntimeError(f"redgifs: {path} nie odpowiedzial poprawnie") # ---- listingi --------------------------------------------------------------------- def latest(self, page: int = 1, count: int = _PAGE_MAX) -> list[dict]: diff --git a/app/scheduler/clips_ingest.py b/app/scheduler/clips_ingest.py index b6899e4..19df4e5 100644 --- a/app/scheduler/clips_ingest.py +++ b/app/scheduler/clips_ingest.py @@ -22,7 +22,7 @@ from sqlalchemy import text from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session -from app.connectors.clips.redgifs import RawClip, RedgifsClient, map_gif +from app.connectors.clips.redgifs import RateLimited, RawClip, RedgifsClient, map_gif from app.db import session_scope from app.models.clip import Clip, ClipTag @@ -148,6 +148,12 @@ def run_redgifs_niches( for page in range(1, per_niche_pages + 1): try: gifs = client.niche_gifs(nid, order=order, page=page) + except RateLimited as e: + # Limit trwaly: koniec przebiegu, wznowienie przez --skip . + # Pierwszy backfill (2026-09-14) mielil 40 nisz po kolei w 429. + log.warning("clips/redgifs niches: %s; przerywam po %d niszach", e, total["niches"]) + total["errors"] += 1 + return total except Exception as e: log.warning("clips/redgifs niche %s strona %d padla: %s", nid, page, e) total["errors"] += 1