MicroFish/backend/app/utils/retry.py

239 lines
7.4 KiB
Python

"""
API-call retry mechanism
Handles retry logic for external API calls (e.g. LLM)
"""
import time
import random
import functools
from typing import Callable, Any, Optional, Type, Tuple
from ..utils.logger import get_logger
logger = get_logger('mirofish.retry')
def retry_with_backoff(
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_factor: float = 2.0,
jitter: bool = True,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
on_retry: Optional[Callable[[Exception, int], None]] = None
):
"""
Retry decorator with exponential backoff
Args:
max_retries: maximum retry count
initial_delay: initial delay (seconds)
max_delay: maximum delay (seconds)
backoff_factor: backoff factor
jitter: whether to add random jitter
exceptions: exception types to retry on
on_retry: callback invoked on each retry (exception, retry_count)
Usage:
@retry_with_backoff(max_retries=3)
def call_llm_api():
...
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> Any:
last_exception = None
delay = initial_delay
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == max_retries:
logger.error(f"Function {func.__name__} still failed after {max_retries} retries: {str(e)}")
raise
# Calculate delay
current_delay = min(delay, max_delay)
if jitter:
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"function {func.__name__} attempt {attempt + 1} failed: {str(e)}, "
f"{current_delay:.1f}s, retrying..."
)
if on_retry:
on_retry(e, attempt + 1)
time.sleep(current_delay)
delay *= backoff_factor
raise last_exception
return wrapper
return decorator
def retry_with_backoff_async(
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_factor: float = 2.0,
jitter: bool = True,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
on_retry: Optional[Callable[[Exception, int], None]] = None
):
"""
Async version of the retry decorator
"""
import asyncio
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(*args, **kwargs) -> Any:
last_exception = None
delay = initial_delay
for attempt in range(max_retries + 1):
try:
return await func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == max_retries:
logger.error(f"async function {func.__name__} still failed after {max_retries} retries: {str(e)}")
raise
current_delay = min(delay, max_delay)
if jitter:
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"async function {func.__name__} attempt {attempt + 1} failed: {str(e)}, "
f"{current_delay:.1f}s, retrying..."
)
if on_retry:
on_retry(e, attempt + 1)
await asyncio.sleep(current_delay)
delay *= backoff_factor
raise last_exception
return wrapper
return decorator
class RetryableAPIClient:
"""
Retry-capable API client wrapper
"""
def __init__(
self,
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_factor: float = 2.0
):
self.max_retries = max_retries
self.initial_delay = initial_delay
self.max_delay = max_delay
self.backoff_factor = backoff_factor
def call_with_retry(
self,
func: Callable,
*args,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
**kwargs
) -> Any:
"""
Execute a function with retry on failure
Args:
func: function to call
*args: function positional args
exceptions: exception types to retry on
**kwargs: function keyword args
Returns:
function return value
"""
last_exception = None
delay = self.initial_delay
for attempt in range(self.max_retries + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == self.max_retries:
logger.error(f"API call still failed after {self.max_retries} retries: {str(e)}")
raise
current_delay = min(delay, self.max_delay)
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"API callattempt {attempt + 1} failed: {str(e)}, "
f"{current_delay:.1f}s, retrying..."
)
time.sleep(current_delay)
delay *= self.backoff_factor
raise last_exception
def call_batch_with_retry(
self,
items: list,
process_func: Callable,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
continue_on_failure: bool = True
) -> Tuple[list, list]:
"""
Batch call with per-item retry on failure
Args:
items: list of items to process
process_func: processing function, takes a single item
exceptions: exception types to retry on
continue_on_failure: whether to keep going after an individual item fails
Returns:
(list of successful results, list of failed items)
"""
results = []
failures = []
for idx, item in enumerate(items):
try:
result = self.call_with_retry(
process_func,
item,
exceptions=exceptions
)
results.append(result)
except Exception as e:
logger.error(f"Processing item {idx + 1} failed: {str(e)}")
failures.append({
"index": idx,
"item": item,
"error": str(e)
})
if not continue_on_failure:
raise
return results, failures