Coverage for agentos/core/retry.py: 0%
133 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +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
17import time
18from dataclasses import dataclass, field
19from enum import Enum
20from typing import Any, Awaitable, Callable, List, Optional, Type, TypeVar
22logger = logging.getLogger(__name__)
24T = TypeVar("T")
27# ============================================================================
28# Config
29# ============================================================================
31class JitterStrategy(str, Enum):
32 """Jitter algorithms for exponential backoff."""
34 NONE = "none" # No jitter — deterministic
35 FULL = "full" # random(0, delay)
36 DECORRELATED = "decorrelated" # random(base, delay) — AWS-style
37 EQUAL = "equal" # delay/2 + random(0, delay/2)
40@dataclass
41class RetryConfig:
42 """Configuration for retry behavior."""
44 max_retries: int = 3
45 base_delay: float = 1.0 # seconds
46 max_delay: float = 60.0 # cap
47 multiplier: float = 2.0 # exponential factor
48 jitter: JitterStrategy = JitterStrategy.DECORRELATED
49 retryable_exceptions: tuple[Type[BaseException], ...] = (Exception,)
50 on_retry: Optional[Callable[[int, Exception, float], None]] = None # (attempt, exc, delay) → None
53# ============================================================================
54# Delay calculators
55# ============================================================================
57def _calc_jitter(delay: float, strategy: JitterStrategy) -> float:
58 """Apply jitter to a computed delay."""
59 if strategy == JitterStrategy.NONE:
60 return delay
61 if strategy == JitterStrategy.FULL:
62 return random.uniform(0, delay)
63 if strategy == JitterStrategy.DECORRELATED:
64 return random.uniform(0, delay)
65 # EQUAL
66 half = delay / 2
67 return half + random.uniform(0, half)
70def exponential_delay(attempt: int, config: RetryConfig) -> float:
71 """Exponential backoff: base * multiplier^(attempt-1)."""
72 delay = config.base_delay * (config.multiplier ** (attempt - 1))
73 delay = min(delay, config.max_delay)
74 return _calc_jitter(delay, config.jitter)
77def fibonacci_delay(attempt: int, config: RetryConfig) -> float:
78 """Fibonacci backoff — gentler than exponential."""
79 a, b = 0, 1
80 for _ in range(attempt):
81 a, b = b, a + b
82 delay = min(a * config.base_delay, config.max_delay)
83 return _calc_jitter(delay, config.jitter)
86def fixed_delay(attempt: int, config: RetryConfig) -> float:
87 """Constant delay between retries."""
88 return min(config.base_delay, config.max_delay)
91DelayFunc = Callable[[int, RetryConfig], float]
94# ============================================================================
95# Retry executor
96# ============================================================================
98@dataclass
99class RetryResult:
100 """Result of a retry operation."""
102 attempts: int
103 success: bool
104 last_exception: Optional[Exception] = None
105 total_delay: float = 0.0
106 attempt_history: List[tuple[int, float, Optional[Exception]]] = field(default_factory=list)
109class Retry:
110 """Async retry executor with configurable backoff.
112 Usage:
113 retry = Retry(RetryConfig(max_retries=3, base_delay=0.5))
114 result = await retry.execute(some_async_fn)
116 # Decorator-style
117 @retry.with_retry
118 async def flaky_call(): ...
119 """
121 def __init__(
122 self,
123 config: RetryConfig,
124 delay_fn: DelayFunc = exponential_delay,
125 ):
126 self.config = config
127 self.delay_fn = delay_fn
129 async def execute(
130 self,
131 fn: Callable[..., Awaitable[T]],
132 *args: Any,
133 **kwargs: Any,
134 ) -> T:
135 """Execute fn with retry logic. Raises last exception if all retries exhausted."""
136 last_exc: Optional[Exception] = None
138 for attempt in range(1, self.config.max_retries + 2): # +2 for initial attempt + retries
139 try:
140 return await fn(*args, **kwargs)
141 except self.config.retryable_exceptions as exc:
142 last_exc = exc
144 if attempt > self.config.max_retries:
145 logger.warning(
146 "Retry exhausted after %d attempts — %s: %s",
147 attempt, type(exc).__name__, exc,
148 )
149 raise
151 delay = self.delay_fn(attempt, self.config)
153 if self.config.on_retry:
154 try:
155 self.config.on_retry(attempt, exc, delay)
156 except Exception:
157 pass # Don't let callback failure break retry
159 logger.debug(
160 "Retry attempt %d/%d after %.2fs — %s",
161 attempt, self.config.max_retries, delay, exc,
162 )
163 await asyncio.sleep(delay)
165 # Unreachable, but type-safe
166 assert last_exc is not None
167 raise last_exc
169 async def execute_with_result(
170 self,
171 fn: Callable[..., Awaitable[T]],
172 *args: Any,
173 **kwargs: Any,
174 ) -> RetryResult:
175 """Execute with retry and return detailed Result."""
176 history: List[tuple[int, float, Optional[Exception]]] = []
177 total_delay = 0.0
178 last_exc: Optional[Exception] = None
180 for attempt in range(1, self.config.max_retries + 2):
181 try:
182 result = await fn(*args, **kwargs)
183 history.append((attempt, 0.0, None))
184 return RetryResult(
185 attempts=attempt,
186 success=True,
187 total_delay=total_delay,
188 attempt_history=history,
189 )
190 except self.config.retryable_exceptions as exc:
191 last_exc = exc
193 if attempt > self.config.max_retries:
194 history.append((attempt, 0.0, exc))
195 return RetryResult(
196 attempts=attempt,
197 success=False,
198 last_exception=exc,
199 total_delay=total_delay,
200 attempt_history=history,
201 )
203 delay = self.delay_fn(attempt, self.config)
204 total_delay += delay
205 history.append((attempt, delay, exc))
206 await asyncio.sleep(delay)
208 return RetryResult(
209 attempts=self.config.max_retries + 1,
210 success=False,
211 last_exception=last_exc,
212 total_delay=total_delay,
213 attempt_history=history,
214 )
216 def with_retry(self, fn: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
217 """Decorator: apply retry logic to an async function."""
219 async def wrapper(*args: Any, **kwargs: Any) -> T:
220 return await self.execute(fn, *args, **kwargs)
222 wrapper.__name__ = fn.__name__
223 wrapper.__doc__ = fn.__doc__
224 wrapper._retry = self # type: ignore[attr-defined]
225 return wrapper
228# ============================================================================
229# Pre-built retry policies
230# ============================================================================
232class RetryPolicies:
233 """Factory for common retry configurations."""
235 @staticmethod
236 def fast() -> Retry:
237 """3 retries, 100ms base, 2x multiplier — for idempotent API calls."""
238 return Retry(RetryConfig(
239 max_retries=3,
240 base_delay=0.1,
241 max_delay=1.0,
242 multiplier=2.0,
243 jitter=JitterStrategy.DECORRELATED,
244 ))
246 @staticmethod
247 def standard() -> Retry:
248 """5 retries, 500ms base, 2x — general purpose."""
249 return Retry(RetryConfig(
250 max_retries=5,
251 base_delay=0.5,
252 max_delay=30.0,
253 multiplier=2.0,
254 jitter=JitterStrategy.DECORRELATED,
255 ))
257 @staticmethod
258 def persistent() -> Retry:
259 """10 retries, 1s base, 2x, max 120s — for critical operations."""
260 return Retry(RetryConfig(
261 max_retries=10,
262 base_delay=1.0,
263 max_delay=120.0,
264 multiplier=2.0,
265 jitter=JitterStrategy.DECORRELATED,
266 ))
268 @staticmethod
269 def immediate() -> Retry:
270 """2 retries, no delay — for infallible operations."""
271 return Retry(RetryConfig(
272 max_retries=2,
273 base_delay=0.0,
274 max_delay=0.0,
275 jitter=JitterStrategy.NONE,
276 ))
278 @staticmethod
279 def gentle() -> Retry:
280 """5 retries, Fibonacci backoff — rate-limit friendly."""
281 return Retry(
282 RetryConfig(
283 max_retries=5,
284 base_delay=0.5,
285 max_delay=60.0,
286 jitter=JitterStrategy.FULL,
287 ),
288 delay_fn=fibonacci_delay,
289 )
292# ============================================================================
293# Sync retry (for non-async contexts)
294# ============================================================================
296class SyncRetry:
297 """Synchronous retry executor — calls asyncio.run internally."""
299 def __init__(self, config: RetryConfig, delay_fn: DelayFunc = exponential_delay):
300 self._async_retry = Retry(config, delay_fn)
302 def execute(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
303 """Synchronous wrapper."""
304 import asyncio as _asyncio
306 async def _wrapper():
307 return fn(*args, **kwargs)
309 return _asyncio.run(self._async_retry.execute(_wrapper))
311 def with_retry(self, fn: Callable[..., T]) -> Callable[..., T]:
312 """Decorator for sync functions."""
314 def wrapper(*args: Any, **kwargs: Any) -> T:
315 return self.execute(fn, *args, **kwargs)
317 wrapper.__name__ = fn.__name__
318 wrapper.__doc__ = fn.__doc__
319 return wrapper