Coverage for agentos/core/retry.py: 95%
132 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 11:37 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 11:37 +0800
1"""AgentOS Retry — production-grade retry with exponential backoff + jitter.
3Strategies:
4- ExponentialBackoff: base * multiplier^n with decorrelated jitter
5- FixedDelay: constant interval
6- FibonacciBackoff: fib sequence for gentler ramp
7- CompositeRetry: chain multiple strategies
9Design: ~350 lines, zero external deps beyond stdlib + asyncio.
10"""
12from __future__ import annotations
14import asyncio
15import logging
16import random
17from dataclasses import dataclass, field
18from enum import Enum
19from typing import Any, Awaitable, Callable, List, Optional, Type, TypeVar
21logger = logging.getLogger(__name__)
23T = TypeVar("T")
26# ============================================================================
27# Config
28# ============================================================================
30class JitterStrategy(str, Enum):
31 """Jitter algorithms for exponential backoff."""
33 NONE = "none" # No jitter — deterministic
34 FULL = "full" # random(0, delay)
35 DECORRELATED = "decorrelated" # random(base, delay) — AWS-style
36 EQUAL = "equal" # delay/2 + random(0, delay/2)
39@dataclass
40class RetryConfig:
41 """Configuration for retry behavior."""
43 max_retries: int = 3
44 base_delay: float = 1.0 # seconds
45 max_delay: float = 60.0 # cap
46 multiplier: float = 2.0 # exponential factor
47 jitter: JitterStrategy = JitterStrategy.DECORRELATED
48 retryable_exceptions: tuple[Type[BaseException], ...] = (Exception,)
49 on_retry: Optional[Callable[[int, Exception, float], None]] = None # (attempt, exc, delay) → None
52# ============================================================================
53# Delay calculators
54# ============================================================================
56def _calc_jitter(delay: float, strategy: JitterStrategy) -> float:
57 """Apply jitter to a computed delay."""
58 if strategy == JitterStrategy.NONE:
59 return delay
60 if strategy == JitterStrategy.FULL:
61 return random.uniform(0, delay)
62 if strategy == JitterStrategy.DECORRELATED:
63 return random.uniform(0, delay)
64 # EQUAL
65 half = delay / 2
66 return half + random.uniform(0, half)
69def exponential_delay(attempt: int, config: RetryConfig) -> float:
70 """Exponential backoff: base * multiplier^(attempt-1)."""
71 delay = config.base_delay * (config.multiplier ** (attempt - 1))
72 delay = min(delay, config.max_delay)
73 return _calc_jitter(delay, config.jitter)
76def fibonacci_delay(attempt: int, config: RetryConfig) -> float:
77 """Fibonacci backoff — gentler than exponential."""
78 a, b = 0, 1
79 for _ in range(attempt):
80 a, b = b, a + b
81 delay = min(a * config.base_delay, config.max_delay)
82 return _calc_jitter(delay, config.jitter)
85def fixed_delay(attempt: int, config: RetryConfig) -> float:
86 """Constant delay between retries."""
87 return min(config.base_delay, config.max_delay)
90DelayFunc = Callable[[int, RetryConfig], float]
93# ============================================================================
94# Retry executor
95# ============================================================================
97@dataclass
98class RetryResult:
99 """Result of a retry operation."""
101 attempts: int
102 success: bool
103 last_exception: Optional[Exception] = None
104 total_delay: float = 0.0
105 attempt_history: List[tuple[int, float, Optional[Exception]]] = field(default_factory=list)
108class Retry:
109 """Async retry executor with configurable backoff.
111 Usage:
112 retry = Retry(RetryConfig(max_retries=3, base_delay=0.5))
113 result = await retry.execute(some_async_fn)
115 # Decorator-style
116 @retry.with_retry
117 async def flaky_call(): ...
118 """
120 def __init__(
121 self,
122 config: RetryConfig,
123 delay_fn: DelayFunc = exponential_delay,
124 ):
125 self.config = config
126 self.delay_fn = delay_fn
128 async def execute(
129 self,
130 fn: Callable[..., Awaitable[T]],
131 *args: Any,
132 **kwargs: Any,
133 ) -> T:
134 """Execute fn with retry logic. Raises last exception if all retries exhausted."""
135 last_exc: Optional[Exception] = None
137 for attempt in range(1, self.config.max_retries + 2): # +2 for initial attempt + retries
138 try:
139 return await fn(*args, **kwargs)
140 except self.config.retryable_exceptions as exc:
141 last_exc = exc
143 if attempt > self.config.max_retries:
144 logger.warning(
145 "Retry exhausted after %d attempts — %s: %s",
146 attempt, type(exc).__name__, exc,
147 )
148 raise
150 delay = self.delay_fn(attempt, self.config)
152 if self.config.on_retry:
153 try:
154 self.config.on_retry(attempt, exc, delay)
155 except Exception:
156 pass # Don't let callback failure break retry
158 logger.debug(
159 "Retry attempt %d/%d after %.2fs — %s",
160 attempt, self.config.max_retries, delay, exc,
161 )
162 await asyncio.sleep(delay)
164 # Unreachable, but type-safe
165 assert last_exc is not None
166 raise last_exc
168 async def execute_with_result(
169 self,
170 fn: Callable[..., Awaitable[T]],
171 *args: Any,
172 **kwargs: Any,
173 ) -> RetryResult:
174 """Execute with retry and return detailed Result."""
175 history: List[tuple[int, float, Optional[Exception]]] = []
176 total_delay = 0.0
177 last_exc: Optional[Exception] = None
179 for attempt in range(1, self.config.max_retries + 2):
180 try:
181 result = await fn(*args, **kwargs)
182 history.append((attempt, 0.0, None))
183 return RetryResult(
184 attempts=attempt,
185 success=True,
186 total_delay=total_delay,
187 attempt_history=history,
188 )
189 except self.config.retryable_exceptions as exc:
190 last_exc = exc
192 if attempt > self.config.max_retries:
193 history.append((attempt, 0.0, exc))
194 return RetryResult(
195 attempts=attempt,
196 success=False,
197 last_exception=exc,
198 total_delay=total_delay,
199 attempt_history=history,
200 )
202 delay = self.delay_fn(attempt, self.config)
203 total_delay += delay
204 history.append((attempt, delay, exc))
205 await asyncio.sleep(delay)
207 return RetryResult(
208 attempts=self.config.max_retries + 1,
209 success=False,
210 last_exception=last_exc,
211 total_delay=total_delay,
212 attempt_history=history,
213 )
215 def with_retry(self, fn: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
216 """Decorator: apply retry logic to an async function."""
218 async def wrapper(*args: Any, **kwargs: Any) -> T:
219 return await self.execute(fn, *args, **kwargs)
221 wrapper.__name__ = fn.__name__
222 wrapper.__doc__ = fn.__doc__
223 wrapper._retry = self # type: ignore[attr-defined]
224 return wrapper
227# ============================================================================
228# Pre-built retry policies
229# ============================================================================
231class RetryPolicies:
232 """Factory for common retry configurations."""
234 @staticmethod
235 def fast() -> Retry:
236 """3 retries, 100ms base, 2x multiplier — for idempotent API calls."""
237 return Retry(RetryConfig(
238 max_retries=3,
239 base_delay=0.1,
240 max_delay=1.0,
241 multiplier=2.0,
242 jitter=JitterStrategy.DECORRELATED,
243 ))
245 @staticmethod
246 def standard() -> Retry:
247 """5 retries, 500ms base, 2x — general purpose."""
248 return Retry(RetryConfig(
249 max_retries=5,
250 base_delay=0.5,
251 max_delay=30.0,
252 multiplier=2.0,
253 jitter=JitterStrategy.DECORRELATED,
254 ))
256 @staticmethod
257 def persistent() -> Retry:
258 """10 retries, 1s base, 2x, max 120s — for critical operations."""
259 return Retry(RetryConfig(
260 max_retries=10,
261 base_delay=1.0,
262 max_delay=120.0,
263 multiplier=2.0,
264 jitter=JitterStrategy.DECORRELATED,
265 ))
267 @staticmethod
268 def immediate() -> Retry:
269 """2 retries, no delay — for infallible operations."""
270 return Retry(RetryConfig(
271 max_retries=2,
272 base_delay=0.0,
273 max_delay=0.0,
274 jitter=JitterStrategy.NONE,
275 ))
277 @staticmethod
278 def gentle() -> Retry:
279 """5 retries, Fibonacci backoff — rate-limit friendly."""
280 return Retry(
281 RetryConfig(
282 max_retries=5,
283 base_delay=0.5,
284 max_delay=60.0,
285 jitter=JitterStrategy.FULL,
286 ),
287 delay_fn=fibonacci_delay,
288 )
291# ============================================================================
292# Sync retry (for non-async contexts)
293# ============================================================================
295class SyncRetry:
296 """Synchronous retry executor — calls asyncio.run internally."""
298 def __init__(self, config: RetryConfig, delay_fn: DelayFunc = exponential_delay):
299 self._async_retry = Retry(config, delay_fn)
301 def execute(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
302 """Synchronous wrapper."""
303 import asyncio as _asyncio
305 async def _wrapper():
306 return fn(*args, **kwargs)
308 return _asyncio.run(self._async_retry.execute(_wrapper))
310 def with_retry(self, fn: Callable[..., T]) -> Callable[..., T]:
311 """Decorator for sync functions."""
313 def wrapper(*args: Any, **kwargs: Any) -> T:
314 return self.execute(fn, *args, **kwargs)
316 wrapper.__name__ = fn.__name__
317 wrapper.__doc__ = fn.__doc__
318 return wrapper