fix(news): rotar feeds de Reddit por run + loguear HTTP >=400
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s

Reddit sin OAuth ratelimita por IP con ventana larga: la 2ª petición
seguida a reddit.com devuelve 429 siempre (verificado: 30s de pausa no
bastan). Como r/UFOs va antes en FEEDS, r/UFOB recibía 429 en cada poll
desde F1 y nunca llegó a sembrar (0 filas en news_seen).

- Solo se pollea un feed de reddit por run, rotando (_reddit_turn por
  proceso). Con poll de 6h cada subreddit se refresca cada 12h.
- El 429 llegaba con bozo=False y 0 entries, invisible para el guard
  existente: ahora status >= 400 se loguea como warning y se salta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-03 07:18:15 +00:00
co-authored by Claude Fable 5
parent 480d86e975
commit dcfe674322
+29
View File
@@ -36,6 +36,16 @@ _SHORT_TOKEN_MAXLEN = 4
# Límite de Telegram 4096; troceamos por debajo con margen. # Límite de Telegram 4096; troceamos por debajo con margen.
_TG_LIMIT = 4000 _TG_LIMIT = 4000
# Reddit sin OAuth ratelimita por IP con ventana larga (>30s verificado): la 2ª
# petición seguida a reddit.com devuelve 429 SIEMPRE, así que solo se pollea un
# feed de reddit por run, rotando. Contador por proceso: tras un reinicio se
# empieza por el primero, lo cual es inocuo.
_reddit_turn = 0
def _is_reddit(feed: dict) -> bool:
return "reddit.com" in feed["url"]
@dataclass @dataclass
class NewsItem: class NewsItem:
@@ -89,13 +99,32 @@ async def poll_feeds(db, settings) -> list[NewsItem]:
Best-effort: cada feed va en su propio try/except — uno caído nunca tumba el Best-effort: cada feed va en su propio try/except — uno caído nunca tumba el
run. feedparser.parse es bloqueante (I/O de red), así que se ejecuta en un run. feedparser.parse es bloqueante (I/O de red), así que se ejecuta en un
hilo para no congelar el event loop del bot durante el poll. hilo para no congelar el event loop del bot durante el poll.
Reddit: solo se pollea UN feed de reddit.com por run, rotando entre ellos
(ver _reddit_turn) — dos peticiones seguidas garantizan un 429 en la 2ª.
""" """
global _reddit_turn
keywords = [k.strip() for k in settings.NEWS_KEYWORDS.split(",") if k.strip()] keywords = [k.strip() for k in settings.NEWS_KEYWORDS.split(",") if k.strip()]
new_items: list[NewsItem] = [] new_items: list[NewsItem] = []
reddit_feeds = [f for f in FEEDS if _is_reddit(f)]
skip: set[str] = set()
if len(reddit_feeds) > 1:
chosen = reddit_feeds[_reddit_turn % len(reddit_feeds)]
_reddit_turn += 1
skip = {f["name"] for f in reddit_feeds if f["name"] != chosen["name"]}
for feed in FEEDS: for feed in FEEDS:
if feed["name"] in skip:
continue
try: try:
parsed = await asyncio.to_thread(feedparser.parse, feed["url"], agent=UA) parsed = await asyncio.to_thread(feedparser.parse, feed["url"], agent=UA)
status = getattr(parsed, "status", None)
# Un 429/403 llega con bozo=False y 0 entries: sin este guard se
# tragaba en silencio (así estuvo r/UFOB sin sembrar desde F1).
if status and status >= 400:
log.warning("feed %s HTTP %s; salto", feed["name"], status)
continue
if parsed.bozo and not parsed.entries: if parsed.bozo and not parsed.entries:
log.warning("feed bozo sin entries: %s (%s)", log.warning("feed bozo sin entries: %s (%s)",
feed["name"], getattr(parsed, "bozo_exception", "")) feed["name"], getattr(parsed, "bozo_exception", ""))