diff --git a/tests/test_sources.py b/tests/test_sources.py index 6427f9fe..7847ec8f 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -123,6 +123,7 @@ def handler(_: httpx.Request) -> httpx.Response: assert attempts == 3 assert sleep.await_count == 2 sleep.assert_awaited_with(4.0) + assert "failed after 3 attempts" in str(caught.value) assert "private.example.invalid" not in str(caught.value) assert caught.value.__cause__ is None @@ -133,7 +134,7 @@ async def test_rss_source_does_not_retry_permanent_http_status(monkeypatch) -> N monkeypatch.setattr("weather_briefing.sources.asyncio.sleep", sleep) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - with pytest.raises(SourceFetchError): + with pytest.raises(SourceFetchError, match="failed after 1 attempt"): await RSSSource(client, max_attempts=3).fetch( FeedConfig("source", "Source", "https://private.example.invalid") ) @@ -153,7 +154,7 @@ def handler(_: httpx.Request) -> httpx.Response: monkeypatch.setattr("weather_briefing.sources.asyncio.sleep", sleep) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - with pytest.raises(SourceFetchError): + with pytest.raises(SourceFetchError, match="failed after 1 attempt"): await RSSSource(client, max_attempts=3).fetch( FeedConfig("source", "Source", "https://private.example.invalid") ) @@ -184,6 +185,26 @@ def handler(request: httpx.Request) -> httpx.Response: assert sleep.await_count == 1 +async def test_rss_source_reports_exhausted_transport_attempts(monkeypatch) -> None: + attempts = 0 + sleep = AsyncMock() + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.ConnectError("connection failed", request=request) + + monkeypatch.setattr("weather_briefing.sources.asyncio.sleep", sleep) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(SourceFetchError, match="failed after 2 attempts"): + await RSSSource(client, max_attempts=2).fetch( + FeedConfig("source", "Source", "https://private.example.invalid") + ) + + 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) diff --git a/weather_briefing/sources.py b/weather_briefing/sources.py index ff1c4cc9..0d78989f 100644 --- a/weather_briefing/sources.py +++ b/weather_briefing/sources.py @@ -139,9 +139,11 @@ async def fetch(self, config: FeedConfig) -> tuple[Article, ...]: return tuple(articles) async def _fetch_with_retry(self, config: FeedConfig) -> str: + attempts_made = 0 for attempt in range(1, self._max_attempts + 1): retry_after: float | None = None try: + attempts_made += 1 response = await self._client.get( config.url, extensions=api_call_extensions("rss", "fetch"), @@ -159,7 +161,8 @@ async def _fetch_with_retry(self, config: FeedConfig) -> str: 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 + attempt_label = "attempt" if attempts_made == 1 else "attempts" + raise SourceFetchError(f"RSS source {config.id} failed after {attempts_made} {attempt_label}") from None class HTTPContextSource: