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
« 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.
3Retries transient failures (network errors, rate limits) while
4immediately propagating permanent failures (authentication errors).
5"""
7from __future__ import annotations
9import functools
10import random
11import time
12from collections.abc import Callable
13from typing import Any
15from loguru import logger
17from lintro.ai.exceptions import (
18 AIAuthenticationError,
19 AIProviderError,
20 AIRateLimitError,
21)
23# Defaults
24DEFAULT_MAX_RETRIES = 3
25DEFAULT_BASE_DELAY = 1.0
26DEFAULT_MAX_DELAY = 30.0
27DEFAULT_BACKOFF_FACTOR = 2.0
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.
39 Retries on ``AIProviderError`` and ``AIRateLimitError``.
40 Does NOT retry on ``AIAuthenticationError`` (permanent failure).
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.
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.
52 Returns:
53 Decorated function with retry behavior.
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)
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
109 return wrapper
111 return decorator