Coverage for src/ecnl/adapters/outbound/retry_adapter.py: 84%
50 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
1"""RetryingAdapter — transparent retry decorator for ECNLAPIPort.
3Wraps any ECNLAPIPort implementation and retries on UpstreamAPIError using
4exponential backoff. ECNLNotFoundError 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 ECNLNotFoundError, UpstreamAPIError
16from ...domain.models import Club, Event, EventOverview, Match, Standings, Team
17from ...ports.outbound import ECNLAPIPort
19logger = logging.getLogger(__name__)
22class RetryingAdapter:
23 """Decorates an ECNLAPIPort with exponential-backoff retry on UpstreamAPIError.
25 ECNLNotFoundError propagates immediately — a 404 will not become a 200 on retry.
26 """
28 def __init__(
29 self,
30 inner: ECNLAPIPort,
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 ECNLAPIPort implementation to wrap.
39 max_attempts: Total attempts before giving up (minimum 1).
40 delay_seconds: Base delay in seconds; doubled on each retry.
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, *args: object) -> object:
49 """Execute an inner port method with retry on UpstreamAPIError.
51 Args:
52 method_name: Name of the method on the inner adapter to call.
53 *args: Positional arguments forwarded to the method.
55 Raises:
56 ECNLNotFoundError: Immediately, without retrying.
57 UpstreamAPIError: After all attempts are exhausted.
58 """
59 method = getattr(self._inner, method_name)
60 last_error: UpstreamAPIError | None = None
61 for attempt in range(self._max_attempts):
62 try:
63 return await method(*args)
64 except ECNLNotFoundError:
65 raise
66 except UpstreamAPIError as exc:
67 last_error = exc
68 if attempt < self._max_attempts - 1:
69 delay = self._delay_seconds * (2**attempt)
70 logger.warning(
71 "Attempt %d/%d failed for %s, retrying in %.1fs: %s",
72 attempt + 1,
73 self._max_attempts,
74 method_name,
75 delay,
76 exc,
77 )
78 await self._sleep(delay)
79 raise last_error # type: ignore[misc]
81 async def get_event(self, event_id: int) -> Event:
82 return await self._retry("get_event", event_id) # type: ignore[return-value]
84 async def get_event_overview(self, event_id: int) -> EventOverview:
85 return await self._retry("get_event_overview", event_id) # type: ignore[return-value]
87 async def get_standings(self, event_id: int, division_id: int, flight_id: int) -> Standings:
88 return await self._retry("get_standings", event_id, division_id, flight_id) # type: ignore[return-value]
90 async def get_schedule(self, event_id: int, flight_id: int) -> list[Match]:
91 return await self._retry("get_schedule", event_id, flight_id) # type: ignore[return-value]
93 async def get_team_schedule(self, event_id: int, team_id: int) -> list[Match]:
94 return await self._retry("get_team_schedule", event_id, team_id) # type: ignore[return-value]
96 async def get_flight_teams(self, flight_id: int) -> list[Team]:
97 return await self._retry("get_flight_teams", flight_id) # type: ignore[return-value]
99 async def get_event_teams(self, event_id: int) -> list[Team]:
100 return await self._retry("get_event_teams", event_id) # type: ignore[return-value]
102 async def get_clubs(self, event_id: int) -> list[Club]:
103 return await self._retry("get_clubs", event_id) # type: ignore[return-value]
105 async def get_match(self, match_token: str) -> dict:
106 return await self._retry("get_match", match_token) # type: ignore[return-value]
108 async def get_brackets(self, event_id: int, flight_id: int) -> dict:
109 return await self._retry("get_brackets", event_id, flight_id) # type: ignore[return-value]
111 async def get_org_club_events(self, org_id: int) -> list[int]:
112 return await self._retry("get_org_club_events", org_id) # type: ignore[return-value]