Coverage for src/usls/adapters/outbound/retry_adapter.py: 95%
44 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-05 10:11 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-05 10:11 +0000
1"""RetryingAdapter — transparent retry decorator for USLSAPIPort.
3Wraps any USLSAPIPort implementation and retries on UpstreamAPIError using
4exponential backoff. USLSNotFoundError is not retried because a 404 is a
5definitive answer, not a transient failure.
7The sleep callable is injectable so tests can assert on backoff timing without
8actually sleeping.
9"""
11import asyncio
12import logging
13from collections.abc import Callable
15from ...domain.exceptions import UpstreamAPIError, USLSNotFoundError
16from ...domain.models import Match, MatchDetails, NewsArticle, Player, Standing, Team
17from ...ports.outbound import USLSAPIPort
19logger = logging.getLogger(__name__)
22class RetryingAdapter:
23 """Decorates an USLSAPIPort with exponential-backoff retry on UpstreamAPIError.
25 USLSNotFoundError propagates immediately — a 404 will not become a 200 on retry.
26 """
28 def __init__(
29 self,
30 inner: USLSAPIPort,
31 max_attempts: int = 3,
32 delay_seconds: float = 1.0,
33 sleep: Callable[[float], object] = asyncio.sleep,
34 ) -> None:
35 """Initialize the retrying adapter.
37 Args:
38 inner: The USLSAPIPort implementation to wrap.
39 max_attempts: Total number of attempts before giving up (minimum 1).
40 delay_seconds: Base delay in seconds; doubled on each retry (exponential backoff).
41 sleep: Async callable used to wait between retries. Injectable for testing.
42 """
43 self._inner = inner
44 self._max_attempts = max(1, max_attempts)
45 self._delay_seconds = delay_seconds
46 self._sleep = sleep
48 async def _retry(self, method_name: str, **kwargs: object) -> object:
49 """Execute a port method with retry on UpstreamAPIError.
51 Raises:
52 USLSNotFoundError: Immediately, without retrying.
53 UpstreamAPIError: After all attempts are exhausted.
54 """
55 method = getattr(self._inner, method_name)
56 last_error: UpstreamAPIError | None = None
57 for attempt in range(self._max_attempts):
58 try:
59 return await method(**kwargs)
60 except USLSNotFoundError:
61 raise
62 except UpstreamAPIError as exc:
63 last_error = exc
64 if attempt < self._max_attempts - 1:
65 delay = self._delay_seconds * (2**attempt)
66 logger.warning(
67 "Attempt %d/%d failed for %s, retrying in %.1fs: %s",
68 attempt + 1,
69 self._max_attempts,
70 method_name,
71 delay,
72 exc,
73 )
74 await self._sleep(delay)
75 raise last_error # type: ignore[misc]
77 async def get_teams(self) -> list[Team]:
78 return await self._retry("get_teams") # type: ignore[return-value]
80 async def get_team(self, team_id: str) -> Team:
81 return await self._retry("get_team", team_id=team_id) # type: ignore[return-value]
83 async def get_scoreboard(self, date: str | None = None, end_date: str | None = None) -> list[Match]:
84 return await self._retry("get_scoreboard", date=date, end_date=end_date) # type: ignore[return-value]
86 async def get_team_schedule(self, team_id: str) -> list[Match]:
87 return await self._retry("get_team_schedule", team_id=team_id) # type: ignore[return-value]
89 async def get_match_details(self, match_id: str) -> MatchDetails:
90 return await self._retry("get_match_details", match_id=match_id) # type: ignore[return-value]
92 async def get_roster(self, team_id: str) -> list[Player]:
93 return await self._retry("get_roster", team_id=team_id) # type: ignore[return-value]
95 async def get_news(self, limit: int) -> list[NewsArticle]:
96 return await self._retry("get_news", limit=limit) # type: ignore[return-value]
98 async def get_standings(self) -> list[Standing]:
99 return await self._retry("get_standings") # type: ignore[return-value]