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

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

2 

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. 

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 UpstreamAPIError, USLSNotFoundError 

16from ...domain.models import Match, MatchDetails, NewsArticle, Player, Standing, Team 

17from ...ports.outbound import USLSAPIPort 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22class RetryingAdapter: 

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

24 

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

26 """ 

27 

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. 

36 

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 

47 

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

49 """Execute a port method with retry on UpstreamAPIError. 

50 

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] 

76 

77 async def get_teams(self) -> list[Team]: 

78 return await self._retry("get_teams") # type: ignore[return-value] 

79 

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] 

82 

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] 

85 

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] 

88 

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] 

91 

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] 

94 

95 async def get_news(self, limit: int) -> list[NewsArticle]: 

96 return await self._retry("get_news", limit=limit) # type: ignore[return-value] 

97 

98 async def get_standings(self) -> list[Standing]: 

99 return await self._retry("get_standings") # type: ignore[return-value]