Honor Retry-After on HTTP 429 (polite rest, not a failure)
Per Codex's spec — a publisher saying "slow down" shouldn't make a feed look broken, but repeated 429s stay visible via last_success_at / stale-source. * Schema: sources.retry_after_at (nullable) + migration. * feeds.parse_retry_after: delta-seconds OR HTTP-date → UTC stamp; ignores invalid/negative/past; caps at now + MAX_BACKOFF_MINUTES. * fetch_feed raises RateLimited (carrying the parsed time) on a 429. * poll_source: on 429 set retry_after_at + last_error, status='rate_limited', and do NOT increment consecutive_failures; on success clear retry_after_at; non-429 failures unchanged. * due_source_rows requires BOTH the streak backoff elapsed AND retry_after_at passed (i.e. the later of the two). * Admin: source_health returns retry_after_at; status reads "rate-limited · rests until …" rather than "failed/resting". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+70
-3
@@ -8,7 +8,7 @@ import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from .enrich import MAX_REDIRECTS, _NoRedirect, _host_is_public
|
||||
@@ -45,6 +45,47 @@ def poll_all_sources(conn: sqlite3.Connection, limit: int | None = None) -> dict
|
||||
MAX_BACKOFF_MINUTES = 1440
|
||||
|
||||
|
||||
class RateLimited(RuntimeError):
|
||||
"""A feed returned HTTP 429. Carries the parsed retry-after timestamp (a UTC
|
||||
'YYYY-MM-DD HH:MM:SS' string, or None) so the caller can rest politely rather
|
||||
than treating it as operational breakage."""
|
||||
|
||||
def __init__(self, message: str, retry_after_at: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after_at = retry_after_at
|
||||
|
||||
|
||||
def parse_retry_after(value: str | None, now: datetime | None = None) -> str | None:
|
||||
"""Parse a Retry-After header → UTC 'YYYY-MM-DD HH:MM:SS', or None.
|
||||
|
||||
Accepts delta-seconds or an HTTP-date; ignores invalid/negative/past values;
|
||||
caps the result at now + MAX_BACKOFF_MINUTES so a feed can't park itself.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
now = now or datetime.now(UTC)
|
||||
value = value.strip()
|
||||
target = None
|
||||
if value.isdigit():
|
||||
target = now + timedelta(seconds=int(value))
|
||||
else:
|
||||
try:
|
||||
dt = email.utils.parsedate_to_datetime(value)
|
||||
except (TypeError, ValueError, IndexError):
|
||||
dt = None
|
||||
if dt is not None:
|
||||
target = dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
||||
if target is None:
|
||||
return None
|
||||
target = target.astimezone(UTC)
|
||||
if target <= now:
|
||||
return None # negative / past → ignore
|
||||
cap = now + timedelta(minutes=MAX_BACKOFF_MINUTES)
|
||||
if target > cap:
|
||||
target = cap
|
||||
return target.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def poll_due_sources(conn: sqlite3.Connection, limit: int | None = None) -> dict:
|
||||
"""Poll only active sources whose last *attempt* (success OR failure) is
|
||||
older than their effective interval, or that have never been polled.
|
||||
@@ -68,6 +109,7 @@ def due_source_rows(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
SELECT s.*
|
||||
FROM sources s
|
||||
WHERE s.active = 1
|
||||
AND (s.retry_after_at IS NULL OR s.retry_after_at <= datetime('now'))
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM ingest_runs r
|
||||
@@ -133,17 +175,39 @@ def poll_source(conn: sqlite3.Connection, source: sqlite3.Row) -> dict:
|
||||
""",
|
||||
(seen, inserted, duplicate, run_id),
|
||||
)
|
||||
# A clean poll resets the failure streak and records the success time.
|
||||
# A clean poll resets the failure streak, clears any rate-limit rest, and
|
||||
# records the success time.
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sources
|
||||
SET last_success_at = CURRENT_TIMESTAMP, consecutive_failures = 0
|
||||
SET last_success_at = CURRENT_TIMESTAMP, consecutive_failures = 0, retry_after_at = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(source["id"],),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "seen": seen, "inserted": inserted, "duplicate": duplicate}
|
||||
except RateLimited as exc:
|
||||
# The publisher asked us to wait — not operational breakage. Record a
|
||||
# polite rest window WITHOUT inflating the failure streak.
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE ingest_runs
|
||||
SET finished_at = CURRENT_TIMESTAMP, status = 'rate_limited',
|
||||
items_seen = ?, items_inserted = ?, items_duplicate = ?, error = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(seen, inserted, duplicate, str(exc), run_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sources SET last_error_at = CURRENT_TIMESTAMP, last_error = ?, retry_after_at = ? WHERE id = ?",
|
||||
(str(exc), exc.retry_after_at, source["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"status": "rate_limited", "seen": seen, "inserted": inserted,
|
||||
"duplicate": duplicate, "retry_after_at": exc.retry_after_at,
|
||||
}
|
||||
except Exception as exc:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -286,6 +350,9 @@ def fetch_feed(url: str, timeout: int = 20) -> bytes:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 429:
|
||||
ra = parse_retry_after(exc.headers.get("Retry-After")) if exc.headers else None
|
||||
raise RateLimited(f"HTTP 429 rate limited fetching {url}", retry_after_at=ra) from exc
|
||||
raise RuntimeError(f"HTTP {exc.code} fetching {url}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"failed fetching {url}: {exc.reason}") from exc
|
||||
|
||||
Reference in New Issue
Block a user