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:
jay
2026-06-09 10:47:40 -04:00
parent 373571b476
commit 38abc26ddd
5 changed files with 146 additions and 4 deletions
+70 -3
View File
@@ -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