Coverage for lintro / ai / retry.py: 76%

50 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Retry decorator for AI API calls with exponential backoff. 

2 

3Retries transient failures (network errors, rate limits) while 

4immediately propagating permanent failures (authentication errors). 

5""" 

6 

7from __future__ import annotations 

8 

9import functools 

10import random 

11import time 

12from collections.abc import Callable 

13from typing import Any 

14 

15from loguru import logger 

16 

17from lintro.ai.exceptions import ( 

18 AIAuthenticationError, 

19 AIProviderError, 

20 AIRateLimitError, 

21) 

22 

23# Defaults 

24DEFAULT_MAX_RETRIES = 3 

25DEFAULT_BASE_DELAY = 1.0 

26DEFAULT_MAX_DELAY = 30.0 

27DEFAULT_BACKOFF_FACTOR = 2.0 

28 

29 

30def with_retry( 

31 *, 

32 max_retries: int = DEFAULT_MAX_RETRIES, 

33 base_delay: float = DEFAULT_BASE_DELAY, 

34 max_delay: float = DEFAULT_MAX_DELAY, 

35 backoff_factor: float = DEFAULT_BACKOFF_FACTOR, 

36) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

37 """Decorator for retrying AI API calls with exponential backoff and jitter. 

38 

39 Retries on ``AIProviderError`` and ``AIRateLimitError``. 

40 Does NOT retry on ``AIAuthenticationError`` (permanent failure). 

41 

42 Each retry delay is computed as ``min(base_delay * factor^attempt, 

43 max_delay)`` then jittered by ±20 % to avoid thundering-herd 

44 alignment when multiple processes retry concurrently. 

45 

46 Args: 

47 max_retries: Maximum number of retry attempts. 

48 base_delay: Initial delay in seconds before the first retry. 

49 max_delay: Maximum delay in seconds between retries. 

50 backoff_factor: Multiplier applied to delay after each attempt. 

51 

52 Returns: 

53 Decorated function with retry behavior. 

54 

55 Raises: 

56 ValueError: If any retry parameter is invalid (negative or 

57 max_delay < base_delay). 

58 """ 

59 if max_retries < 0: 

60 msg = f"max_retries must be >= 0, got {max_retries}" 

61 raise ValueError(msg) 

62 if base_delay < 0: 

63 msg = f"base_delay must be >= 0, got {base_delay}" 

64 raise ValueError(msg) 

65 if max_delay < 0: 

66 msg = f"max_delay must be >= 0, got {max_delay}" 

67 raise ValueError(msg) 

68 if backoff_factor <= 0: 

69 msg = f"backoff_factor must be > 0, got {backoff_factor}" 

70 raise ValueError(msg) 

71 if max_delay < base_delay: 

72 msg = f"max_delay ({max_delay}) must be >= base_delay ({base_delay})" 

73 raise ValueError(msg) 

74 

75 def decorator( 

76 func: Callable[..., Any], 

77 ) -> Callable[..., Any]: 

78 @functools.wraps(func) 

79 def wrapper(*args: Any, **kwargs: Any) -> Any: 

80 last_exception: Exception | None = None 

81 for attempt in range(max_retries + 1): 

82 try: 

83 return func(*args, **kwargs) 

84 except AIAuthenticationError: 

85 raise # Never retry auth errors 

86 except (AIProviderError, AIRateLimitError) as e: 

87 last_exception = e 

88 if attempt == max_retries: 

89 raise 

90 delay = min( 

91 base_delay * (backoff_factor**attempt), 

92 max_delay, 

93 ) 

94 # Jitter ±20% to prevent thundering-herd alignment 

95 # across concurrent lintro processes. Not used for 

96 # security/cryptographic purposes. 

97 delay *= random.uniform(0.8, 1.2) # nosec B311 # noqa: S311 

98 delay = min(delay, max_delay) 

99 logger.debug( 

100 f"AI retry {attempt + 1}/{max_retries}: {e}, " 

101 f"waiting {delay:.1f}s", 

102 ) 

103 time.sleep(delay) 

104 assert ( 

105 last_exception is not None 

106 ), "Retry loop exhausted without capturing an exception" 

107 raise last_exception 

108 

109 return wrapper 

110 

111 return decorator