From 82586beac6b1ebe3e96aadb9e7160ca565d81a41 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:08:45 +0800 Subject: [PATCH] fix: restrict RSS retries to transient failures --- docs/design.md | 2 +- docs/requirements.md | 2 +- tests/test_sources.py | 97 ++++++++++++++++++++++++++++++++++++- weather_briefing/sources.py | 32 +++++++++++- 4 files changed, 128 insertions(+), 5 deletions(-) diff --git a/docs/design.md b/docs/design.md index 1e0ae002..c9238859 100644 --- a/docs/design.md +++ b/docs/design.md @@ -110,7 +110,7 @@ SQLite 没有原生日期时间类型,状态存储需要直接对 TEXT 做范 RSS 是可选补充源,其失败不影响任务成功率;天气 API 是主要信息来源。 -**获取失败** — 单个 RSS 源经可配置次数重试(默认 3 次,间隔 3–5 秒)后仍无法获取或解析。行为:记录警告日志,本次运行继续使用已成功获取的 RSS 内容和天气 API 数据。 +**获取失败** — 单个 RSS 源只对传输错误和临时 HTTP 状态(408、425、429、500、502、503、504)重试,其他 HTTP 错误立即失败。重试默认最多 3 次、随机间隔 3–5 秒;有效 `Retry-After` 指定更长退避时优先遵守。请求仍无法获取或解析时记录警告日志,本次运行继续使用已成功获取的 RSS 内容和天气 API 数据。 **长期无更新** — RSS 源在配置的小时数(默认 24)内未曾见到任何新文章。判定基准为 `source_health` 表中记录的最后一次文章时间,不受当天本地日期筛选影响。 diff --git a/docs/requirements.md b/docs/requirements.md index 542bd0e3..82555770 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -35,7 +35,7 @@ ## 可靠性 -1. RSS 请求失败后随机等待 3–5 秒并重试;重试次数可配置。 +1. RSS 只重试传输错误,以及 408、425、429、500、502、503、504 HTTP 状态;其他 HTTP 错误立即失败。重试默认随机等待 3–5 秒,服务端返回有效 `Retry-After` 时至少等待其指定时长;重试次数可配置。 2. 重试耗尽则记录警告日志并继续任务;RSS 是可选补充源,其获取失败不终止任务,但连续失败达到阈值时独立发出运维告警。连续失败和长期无更新告警均采用至少一次投递;投递或状态写入失败时记录日志并在后续任务中重试,不得使简报任务失败,接收端应按告警类型、源集合和失败周期去重。 3. 任务发生非 RSS 错误时立即向运维通知渠道报警;投递失败时在后续任务失败中重试,成功投递后同一轮失败不重复告警,任务成功后重新开放。RSS 获取连续失败达到可配置阈值(默认 3)时独立报警,与任务失败隔离。任一已配置 RSS 源超过配置的时长(默认 24 小时)没有新文章也触发报警。未配置 RSS 不属于故障。 diff --git a/tests/test_sources.py b/tests/test_sources.py index 95a1f9d2..6427f9fe 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -1,10 +1,11 @@ from unittest.mock import AsyncMock import httpx +import pendulum import pytest from weather_briefing.models import ContextSourceConfig, FeedConfig, SourceDocument -from weather_briefing.sources import HTTPContextSource, RSSSource, SourceFetchError +from weather_briefing.sources import HTTPContextSource, RSSSource, SourceFetchError, _retry_after_seconds async def test_rss_source_marks_configured_verbatim_article(caplog) -> None: @@ -126,6 +127,100 @@ def handler(_: httpx.Request) -> httpx.Response: assert caught.value.__cause__ is None +async def test_rss_source_does_not_retry_permanent_http_status(monkeypatch) -> None: + handler = AsyncMock(return_value=httpx.Response(404)) + sleep = AsyncMock() + monkeypatch.setattr("weather_briefing.sources.asyncio.sleep", sleep) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(SourceFetchError): + await RSSSource(client, max_attempts=3).fetch( + FeedConfig("source", "Source", "https://private.example.invalid") + ) + + assert handler.await_count == 1 + sleep.assert_not_awaited() + + +async def test_rss_source_does_not_retry_other_http_errors(monkeypatch) -> None: + attempts = 0 + sleep = AsyncMock() + + def handler(_: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.HTTPError("invalid request") + + monkeypatch.setattr("weather_briefing.sources.asyncio.sleep", sleep) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(SourceFetchError): + await RSSSource(client, max_attempts=3).fetch( + FeedConfig("source", "Source", "https://private.example.invalid") + ) + + assert attempts == 1 + sleep.assert_not_awaited() + + +async def test_rss_source_retries_transport_errors(monkeypatch) -> None: + attempts = 0 + sleep = AsyncMock() + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise httpx.ConnectError("connection failed", request=request) + return httpx.Response(200, text="") + + monkeypatch.setattr("weather_briefing.sources.asyncio.sleep", sleep) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + articles = await RSSSource(client, max_attempts=2).fetch( + FeedConfig("source", "Source", "https://private.example.invalid") + ) + + assert articles == () + assert attempts == 2 + assert sleep.await_count == 1 + + +async def test_rss_source_respects_retry_after(monkeypatch) -> None: + sleep = AsyncMock() + monkeypatch.setattr("weather_briefing.sources.asyncio.sleep", sleep) + monkeypatch.setattr("weather_briefing.sources.random.uniform", lambda _low, _high: 4.0) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(429, headers={"Retry-After": "12"})) + ) as client: + with pytest.raises(SourceFetchError): + await RSSSource(client, max_attempts=2).fetch( + FeedConfig("source", "Source", "https://private.example.invalid") + ) + + sleep.assert_awaited_once_with(12.0) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("invalid", None), + ("Thu, 16 Jul 2026 00:00:10", None), + ("Thu, 16 Jul 2026 00:00:10 GMT", 10.0), + ("Thu, 16 Jul 2025 00:00:00 GMT", 0.0), + ("-1", 0.0), + ], +) +def test_retry_after_parsing(monkeypatch, value: str | None, expected: float | None) -> None: + monkeypatch.setattr( + "weather_briefing.sources.pendulum.now", + lambda _timezone: pendulum.datetime(2026, 7, 16, tz="UTC"), + ) + headers = {} if value is None else {"Retry-After": value} + + assert _retry_after_seconds(httpx.Response(503, headers=headers)) == expected + + async def test_rss_local_offset_is_normalized_then_restored() -> None: xml = """x oneLocal publication diff --git a/weather_briefing/sources.py b/weather_briefing/sources.py index a82ffb5b..ff1c4cc9 100644 --- a/weather_briefing/sources.py +++ b/weather_briefing/sources.py @@ -6,6 +6,7 @@ import hashlib import logging import random +from email.utils import parsedate_to_datetime from time import struct_time from typing import Protocol @@ -18,6 +19,7 @@ from .models import Article, ContextSourceConfig, FeedConfig, SourceDocument _LOGGER = logging.getLogger("weather_briefing.sources") +_RETRYABLE_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504}) class SourceFetchError(RuntimeError): @@ -54,6 +56,23 @@ def _entry_content(entry: feedparser.FeedParserDict) -> str: return str(entry.get("summary", "")).strip() +def _retry_after_seconds(response: httpx.Response) -> float | None: + value = response.headers.get("Retry-After") + if value is None: + return None + try: + seconds = int(value) + except ValueError: + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError, OverflowError): + return None + if retry_at.tzinfo is None: + return None + seconds = (pendulum.instance(retry_at) - pendulum.now("UTC")).total_seconds() + return max(0.0, float(seconds)) + + class RSSSource: """Fetch, retry, clean, and normalize RSS feed entries.""" @@ -121,6 +140,7 @@ async def fetch(self, config: FeedConfig) -> tuple[Article, ...]: async def _fetch_with_retry(self, config: FeedConfig) -> str: for attempt in range(1, self._max_attempts + 1): + retry_after: float | None = None try: response = await self._client.get( config.url, @@ -128,9 +148,17 @@ async def _fetch_with_retry(self, config: FeedConfig) -> str: ) response.raise_for_status() return response.text + except httpx.TransportError: + pass + except httpx.HTTPStatusError as exc: + if exc.response.status_code not in _RETRYABLE_STATUS_CODES: + break + retry_after = _retry_after_seconds(exc.response) except httpx.HTTPError: - if attempt < self._max_attempts: - await asyncio.sleep(random.uniform(self._retry_min_seconds, self._retry_max_seconds)) + break + if attempt < self._max_attempts: + delay = random.uniform(self._retry_min_seconds, self._retry_max_seconds) + await asyncio.sleep(max(delay, retry_after or 0.0)) raise SourceFetchError(f"RSS source {config.id} failed after {self._max_attempts} attempts") from None