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

1"""RetryingAdapter — transparent retry decorator for ECNLAPIPort. 

2 

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. 

6 

7The sleep callable is injectable so tests can assert on backoff timing without 

8actually sleeping. 

9""" 

10 

11import asyncio 

12import logging 

13from collections.abc import Callable 

14 

15from ...domain.exceptions import ECNLNotFoundError, UpstreamAPIError 

16from ...domain.models import Club, Event, EventOverview, Match, Standings, Team 

17from ...ports.outbound import ECNLAPIPort 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22class RetryingAdapter: 

23 """Decorates an ECNLAPIPort with exponential-backoff retry on UpstreamAPIError. 

24 

25 ECNLNotFoundError propagates immediately — a 404 will not become a 200 on retry. 

26 """ 

27 

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. 

36 

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 

47 

48 async def _retry(self, method_name: str, *args: object) -> object: 

49 """Execute an inner port method with retry on UpstreamAPIError. 

50 

51 Args: 

52 method_name: Name of the method on the inner adapter to call. 

53 *args: Positional arguments forwarded to the method. 

54 

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] 

80 

81 async def get_event(self, event_id: int) -> Event: 

82 return await self._retry("get_event", event_id) # type: ignore[return-value] 

83 

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] 

86 

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] 

89 

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] 

92 

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] 

95 

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] 

98 

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] 

101 

102 async def get_clubs(self, event_id: int) -> list[Club]: 

103 return await self._retry("get_clubs", event_id) # type: ignore[return-value] 

104 

105 async def get_match(self, match_token: str) -> dict: 

106 return await self._retry("get_match", match_token) # type: ignore[return-value] 

107 

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] 

110 

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]