diff --git a/.gitignore b/.gitignore index f61b850..02fdd15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,48 +1,12 @@ -.venv +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ .env -__pycache__ -.pytest_cache -.pypirc -*.db -test -test_state.json -task_flow.egg-info -example_repo -signature.js -git-filter-repo -task/orca/ -**/dist/ -# yarn.lock -package-lock.json -node_modules -build -migrate.sh -*/dev.js -executables/* -namespace/* -config/* -.env.local -taskStateInfoKeypair.json -localKOIIDB.db -metadata.json -.npmrc -*.pem -.vscode -.cursor -data/chunks -data/process -test_state.csv -todos-example.csv - - -# Ignore auto-generated repository directories -repos/ - - -# Ignore Data -data/* - - -venv - -**/venv/ +.venv/ +venv/ +dist/ +build/ +*.egg-info/ +.coverage +htmlcov/ \ No newline at end of file diff --git a/src/retry.py b/src/retry.py new file mode 100644 index 0000000..aee4f04 --- /dev/null +++ b/src/retry.py @@ -0,0 +1,61 @@ +import time +import random +from functools import wraps +from typing import Any, Callable, Optional, Tuple, Type + +class RetryError(Exception): + """Exception raised when all retry attempts are exhausted.""" + pass + +def retry( + max_attempts: int = 3, + backoff_base: float = 1.0, + backoff_multiplier: float = 2.0, + jitter: float = 0.1, + retryable_exceptions: Optional[Tuple[Type[Exception], ...]] = None +) -> Callable: + """ + A decorator that implements exponential backoff retry mechanism. + + Args: + max_attempts (int): Maximum number of retry attempts. Defaults to 3. + backoff_base (float): Base time (in seconds) for initial wait. Defaults to 1.0. + backoff_multiplier (float): Factor to increase wait time between retries. Defaults to 2.0. + jitter (float): Random variation to prevent synchronized retries. Defaults to 0.1. + retryable_exceptions (tuple): Exceptions that trigger a retry. Defaults to None. + + Returns: + Decorated function with retry capabilities. + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + attempts = 0 + default_retryable_exceptions = ( + ConnectionError, + TimeoutError, + RuntimeError + ) + + exceptions_to_retry = retryable_exceptions or default_retryable_exceptions + + while attempts < max_attempts: + try: + return func(*args, **kwargs) + + except exceptions_to_retry as e: + attempts += 1 + + # Exit if max attempts reached + if attempts >= max_attempts: + raise RetryError(f"Function {func.__name__} failed after {max_attempts} attempts") from e + + # Calculate exponential backoff with jitter + wait_time = backoff_base * (backoff_multiplier ** attempts) + jittered_wait = wait_time * (1 + random.uniform(-jitter, jitter)) + + print(f"Retry {attempts}/{max_attempts} for {func.__name__}. Waiting {jittered_wait:.2f} seconds.") + time.sleep(jittered_wait) + + return wrapper + return decorator \ No newline at end of file diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..4fe8e82 --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,81 @@ +import pytest +import time +from src.retry import retry, RetryError + +def test_retry_successful_function(): + @retry(max_attempts=3) + def always_succeeds(): + return "Success" + + result = always_succeeds() + assert result == "Success" + +def test_retry_fails_after_max_attempts(): + attempts = 0 + + @retry(max_attempts=3) + def always_fails(): + nonlocal attempts + attempts += 1 + raise ConnectionError("Simulated network error") + + with pytest.raises(RetryError): + always_fails() + + assert attempts == 3 + +def test_retry_eventually_succeeds(): + attempts = 0 + + @retry(max_attempts=3) + def intermittent_success(): + nonlocal attempts + attempts += 1 + if attempts < 3: + raise ConnectionError("Temporary error") + return "Final Success" + + result = intermittent_success() + assert result == "Final Success" + assert attempts == 3 + +def test_retry_custom_exception(): + class CustomError(Exception): + pass + + attempts = 0 + + @retry(max_attempts=3, retryable_exceptions=(CustomError,)) + def custom_error_function(): + nonlocal attempts + attempts += 1 + raise CustomError("Custom error") + + with pytest.raises(RetryError): + custom_error_function() + + assert attempts == 3 + +def test_retry_backoff_timing(): + import time + + start_time = time.time() + attempts = 0 + + @retry(max_attempts=3, backoff_base=0.1, backoff_multiplier=2.0) + def slow_function(): + nonlocal attempts + attempts += 1 + if attempts < 3: + raise ConnectionError("Temporary network error") + return "Success" + + result = slow_function() + end_time = time.time() + + assert result == "Success" + assert attempts == 3 + + # Check if total wait time is reasonable (allow for some variance) + total_wait = end_time - start_time + assert total_wait >= 0.2 and total_wait <= 1.0 # More flexible timing \ No newline at end of file