crawler-intra-mart/utils/retry.py
Do Duy eb46e739e7 Add intra-mart documentation crawler
Playwright-based crawler that renders pages from api.intra-mart.jp and
document.intra-mart.jp, extracts main content, and converts to Markdown
via Pandoc. SQLite-backed queue drives a resumable sequential pipeline
across crawler, extractor, converter, and storage modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:29:54 +07:00

275 lines
4.9 KiB
Python

import asyncio
from functools import wraps
import random
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
before_sleep_log
)
from utils.logger import logger
class RetryConfig:
"""
Global retry configuration.
"""
DEFAULT_ATTEMPTS = 3
DEFAULT_MIN_WAIT = 1
DEFAULT_MAX_WAIT = 10
DEFAULT_JITTER = 0.5
def retry_sync(
attempts=RetryConfig.DEFAULT_ATTEMPTS,
min_wait=RetryConfig.DEFAULT_MIN_WAIT,
max_wait=RetryConfig.DEFAULT_MAX_WAIT,
exceptions=(Exception,)
):
"""
Retry decorator for sync functions.
Example:
@retry_sync()
def task():
...
"""
return retry(
stop=stop_after_attempt(attempts),
wait=wait_exponential(
multiplier=1,
min=min_wait,
max=max_wait
),
retry=retry_if_exception_type(
exceptions
),
before_sleep=before_sleep_log(
logger,
"WARNING"
),
reraise=True
)
def retry_async(
attempts=RetryConfig.DEFAULT_ATTEMPTS,
min_wait=RetryConfig.DEFAULT_MIN_WAIT,
max_wait=RetryConfig.DEFAULT_MAX_WAIT,
jitter=RetryConfig.DEFAULT_JITTER,
exceptions=(Exception,)
):
"""
Retry decorator for async functions.
Example:
@retry_async()
async def fetch():
...
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(
1,
attempts + 1
):
try:
logger.debug(
f"Attempt {attempt}/"
f"{attempts}: "
f"{func.__name__}"
)
return await func(
*args,
**kwargs
)
except exceptions as e:
last_exception = e
logger.warning(
f"Retry failed "
f"{attempt}/{attempts} "
f"for {func.__name__}: {e}"
)
if attempt >= attempts:
break
base_sleep = min(
min_wait * (2 ** (attempt - 1)),
max_wait
)
sleep_time = base_sleep + random.uniform(
0,
jitter
)
logger.info(
f"Sleeping "
f"{sleep_time:.2f}s before retry"
)
await asyncio.sleep(
sleep_time
)
logger.error(
f"Max retries exceeded "
f"for {func.__name__}"
)
raise last_exception
return wrapper
return decorator
async def retry_operation(
coro,
attempts=3,
delay=1,
exceptions=(Exception,)
):
"""
Retry arbitrary async coroutine.
Example:
result = await retry_operation(
fetch_page(url)
)
"""
last_exception = None
for attempt in range(
1,
attempts + 1
):
try:
logger.debug(
f"Retry operation "
f"attempt {attempt}/{attempts}"
)
return await coro
except exceptions as e:
last_exception = e
logger.warning(
f"Retry operation failed "
f"{attempt}/{attempts}: {e}"
)
if attempt >= attempts:
break
await asyncio.sleep(delay)
logger.error(
"Retry operation exceeded "
"max attempts."
)
raise last_exception
def safe_execute(
func,
*args,
default=None,
log_error=True,
**kwargs
):
"""
Execute function safely.
Returns default value if failed.
Example:
result = safe_execute(
parse_html,
html,
default=""
)
"""
try:
return func(
*args,
**kwargs
)
except Exception as e:
if log_error:
logger.exception(
f"Safe execute failed "
f"{func.__name__}: {e}"
)
return default
async def safe_execute_async(
func,
*args,
default=None,
log_error=True,
**kwargs
):
"""
Safe async execution.
"""
try:
return await func(
*args,
**kwargs
)
except Exception as e:
if log_error:
logger.exception(
f"Safe async execute failed "
f"{func.__name__}: {e}"
)
return default