Coverage for src/wsl/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 WSLAPIPort.
3Wraps any WSLAPIPort implementation and retries on UpstreamAPIError using
4exponential backoff. WSLNotFoundError 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, WSLNotFoundError
16from ...domain.models import Match, MatchDetails, NewsArticle, Player, Standing, Team
17from ...ports.outbound import WSLAPIPort
19logger = logging.getLogger(__name__)
22class RetryingAdapter:
23 """Decorates an WSLAPIPort with exponential-backoff retry on UpstreamAPIError.
25 WSLNotFoundError propagates immediately — a 404 will not become a 200 on retry.
26 """
28 def __init__(
29 self,
30 inner: WSLAPIPort,
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 WSLAPIPort 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 WSLNotFoundError: 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 WSLNotFoundError:
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]