adobe-to-docusign-migrator/src/utils/retry.py

103 lines
3.1 KiB
Python

"""
retry.py
--------
Exponential backoff retry helpers for API calls that may hit rate limits
or transient server errors (429, 502, 503, 504).
"""
from __future__ import annotations
import asyncio
import functools
import logging
import time
from typing import Callable, TypeVar
logger = logging.getLogger(__name__)
T = TypeVar("T")
# HTTP status codes that are safe to retry
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
def retry_with_backoff(
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
retryable_exceptions: tuple = (Exception,),
):
"""
Decorator for sync functions. Retries on exceptions with exponential backoff.
Usage:
@retry_with_backoff(max_retries=3, base_delay=1.0)
def my_api_call():
...
"""
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
last_exc: Exception | None = None
for attempt in range(max_retries + 1):
try:
return fn(*args, **kwargs)
except retryable_exceptions as exc:
last_exc = exc
if attempt == max_retries:
break
delay = min(base_delay * (2 ** attempt), max_delay)
logger.warning(
"Retry %d/%d for %s after %.1fs — %s",
attempt + 1, max_retries, fn.__name__, delay, exc,
)
time.sleep(delay)
raise last_exc
return wrapper
return decorator
def async_retry_with_backoff(
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
retryable_exceptions: tuple = (Exception,),
):
"""
Decorator for async functions. Retries on exceptions with exponential backoff.
Usage:
@async_retry_with_backoff(max_retries=3, base_delay=1.0)
async def my_api_call():
...
"""
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
last_exc: Exception | None = None
for attempt in range(max_retries + 1):
try:
return await fn(*args, **kwargs)
except retryable_exceptions as exc:
last_exc = exc
if attempt == max_retries:
break
delay = min(base_delay * (2 ** attempt), max_delay)
logger.warning(
"Async retry %d/%d for %s after %.1fs — %s",
attempt + 1, max_retries, fn.__name__, delay, exc,
)
await asyncio.sleep(delay)
raise last_exc
return wrapper
return decorator
class RateLimitError(Exception):
"""Raised when an API returns HTTP 429 Too Many Requests."""
def check_response_retryable(status_code: int) -> bool:
"""Return True if the HTTP status code warrants a retry."""
return status_code in _RETRYABLE_STATUS