Coverage for excalidraw_mcp/retry_utils.py: 86%
96 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-16 08:08 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-16 08:08 -0700
1"""Retry utilities with exponential backoff and jitter for robust error handling."""
3import asyncio
4import functools
5import logging
6import random
7import time
8from collections.abc import Awaitable, Callable
9from typing import Any, TypeVar
11from .config import config
13logger = logging.getLogger(__name__)
15T = TypeVar("T")
18class RetryConfig:
19 """Configuration for retry behavior."""
21 def __init__(
22 self,
23 max_attempts: int = 3,
24 base_delay: float = 1.0,
25 max_delay: float = 60.0,
26 exponential_base: float = 2.0,
27 jitter: bool = True,
28 jitter_factor: float = 0.1,
29 ) -> None:
30 self.max_attempts = max_attempts
31 self.base_delay = base_delay
32 self.max_delay = max_delay
33 self.exponential_base = exponential_base
34 self.jitter = jitter
35 self.jitter_factor = jitter_factor
38def calculate_delay(
39 attempt: int, config: RetryConfig, exception: Exception | None = None
40) -> float:
41 """Calculate delay with exponential backoff and optional jitter."""
42 # Exponential backoff: base_delay * (exponential_base ^ attempt)
43 delay = config.base_delay * (config.exponential_base**attempt)
45 # Cap at max_delay
46 delay = min(delay, config.max_delay)
48 # Add jitter if enabled
49 if config.jitter:
50 jitter_amount = delay * config.jitter_factor
51 delay += random.uniform(-jitter_amount, jitter_amount)
52 # Ensure delay doesn't go below base_delay
53 delay = max(delay, config.base_delay)
55 return delay
58async def retry_async[T](
59 func: Callable[..., Awaitable[T]],
60 retry_config: RetryConfig | None = None,
61 retry_on_exceptions: tuple[type[Exception], ...] | None = None,
62 on_retry: Callable[[int, Exception], Awaitable[None]] | None = None,
63) -> T:
64 """Execute an async function with retry logic.
66 Args:
67 func: The async function to execute
68 retry_config: Configuration for retry behavior
69 retry_on_exceptions: Tuple of exception types to retry on
70 on_retry: Optional callback function called on each retry
72 Returns:
73 The result of the function call
75 Raises:
76 The last exception encountered after all retries are exhausted
77 """
78 if retry_config is None:
79 retry_config = RetryConfig(
80 max_attempts=config.server.sync_retry_attempts,
81 base_delay=config.server.sync_retry_delay_seconds,
82 )
84 if retry_on_exceptions is None:
85 retry_on_exceptions = (Exception,)
87 last_exception: Exception | None = None
89 for attempt in range(retry_config.max_attempts):
90 try:
91 return await func()
92 except retry_on_exceptions as e:
93 last_exception = e
95 # If this is the last attempt, don't retry
96 if attempt == retry_config.max_attempts - 1:
97 logger.error(
98 f"Function {func.__name__} failed after {retry_config.max_attempts} attempts. "
99 f"Last error: {e}"
100 )
101 raise
103 # Calculate delay
104 delay = calculate_delay(attempt, retry_config, e)
106 logger.warning(
107 f"Attempt {attempt + 1} of {func.__name__} failed: {e}. "
108 f"Retrying in {delay:.2f} seconds..."
109 )
111 # Call retry callback if provided
112 if on_retry:
113 try:
114 await on_retry(attempt + 1, e)
115 except Exception as callback_error:
116 logger.warning(f"Error in retry callback: {callback_error}")
118 # Wait before retrying
119 await asyncio.sleep(delay)
121 # This should never be reached due to the raise in the loop,
122 # but included for type safety
123 if last_exception:
124 raise last_exception
125 raise RuntimeError("Retry loop ended without an exception")
128def retry_sync[T](
129 func: Callable[..., T],
130 retry_config: RetryConfig | None = None,
131 retry_on_exceptions: tuple[type[Exception], ...] | None = None,
132 on_retry: Callable[[int, Exception], None] | None = None,
133) -> T:
134 """Execute a sync function with retry logic.
136 Args:
137 func: The sync function to execute
138 retry_config: Configuration for retry behavior
139 retry_on_exceptions: Tuple of exception types to retry on
140 on_retry: Optional callback function called on each retry
142 Returns:
143 The result of the function call
145 Raises:
146 The last exception encountered after all retries are exhausted
147 """
148 if retry_config is None:
149 retry_config = RetryConfig(
150 max_attempts=config.server.sync_retry_attempts,
151 base_delay=config.server.sync_retry_delay_seconds,
152 )
154 if retry_on_exceptions is None:
155 retry_on_exceptions = (Exception,)
157 last_exception: Exception | None = None
159 for attempt in range(retry_config.max_attempts):
160 try:
161 return func()
162 except retry_on_exceptions as e:
163 last_exception = e
165 # If this is the last attempt, don't retry
166 if attempt == retry_config.max_attempts - 1:
167 logger.error(
168 f"Function {func.__name__} failed after {retry_config.max_attempts} attempts. "
169 f"Last error: {e}"
170 )
171 raise
173 # Calculate delay
174 delay = calculate_delay(attempt, retry_config, e)
176 logger.warning(
177 f"Attempt {attempt + 1} of {func.__name__} failed: {e}. "
178 f"Retrying in {delay:.2f} seconds..."
179 )
181 # Call retry callback if provided
182 if on_retry:
183 try:
184 on_retry(attempt + 1, e)
185 except Exception as callback_error:
186 logger.warning(f"Error in retry callback: {callback_error}")
188 # Wait before retrying
189 time.sleep(delay)
191 # This should never be reached due to the raise in the loop,
192 # but included for type safety
193 if last_exception:
194 raise last_exception
195 raise RuntimeError("Retry loop ended without an exception")
198def retry_decorator(
199 retry_config: RetryConfig | None = None,
200 retry_on_exceptions: tuple[type[Exception], ...] | None = None,
201 on_retry: Callable[[int, Exception], Any] | None = None,
202) -> Callable[[Callable[..., T]], Callable[..., T]]:
203 """Decorator for adding retry logic to functions.
205 Args:
206 retry_config: Configuration for retry behavior
207 retry_on_exceptions: Tuple of exception types to retry on
208 on_retry: Optional callback function called on each retry
210 Returns:
211 Decorated function with retry logic
212 """
214 def decorator(func: Callable[..., T]) -> Callable[..., T]:
215 @functools.wraps(func)
216 def wrapper(*args: Any, **kwargs: Any) -> T:
217 def sync_func() -> T:
218 return func(*args, **kwargs)
220 return retry_sync(
221 sync_func,
222 retry_config=retry_config,
223 retry_on_exceptions=retry_on_exceptions,
224 on_retry=on_retry,
225 )
227 @functools.wraps(func)
228 async def async_wrapper(*args: Any, **kwargs: Any) -> T:
229 async def async_func() -> T:
230 result = func(*args, **kwargs)
231 if asyncio.iscoroutine(result):
232 coro_result: T = await result
233 return coro_result
234 return result
236 return await retry_async(
237 async_func,
238 retry_config=retry_config,
239 retry_on_exceptions=retry_on_exceptions,
240 on_retry=on_retry,
241 )
243 # Check if function is async
244 if asyncio.iscoroutinefunction(func):
245 return async_wrapper # type: ignore
246 return wrapper
248 return decorator