From fae01129559d66ca3f387e7a25b8a561ec46bbc1 Mon Sep 17 00:00:00 2001 From: Aditya <97450298+1234-ad@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:35:39 +0530 Subject: [PATCH 1/4] Add retry decorator with exponential backoff Implements a configurable retry decorator for handling flaky API calls and network operations. Features: - Exponential backoff with configurable base and max delay - Customizable exception types to catch - Comprehensive logging for debugging - Type hints for better IDE support Addresses issue #27 --- src/agentunit/core/utils.py | 85 +++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/agentunit/core/utils.py diff --git a/src/agentunit/core/utils.py b/src/agentunit/core/utils.py new file mode 100644 index 0000000..0f5c626 --- /dev/null +++ b/src/agentunit/core/utils.py @@ -0,0 +1,85 @@ +""" +Utility functions for AgentUnit core functionality. +""" + +import functools +import logging +import time +from typing import Any, Callable, TypeVar, cast + +logger = logging.getLogger(__name__) + +F = TypeVar("F", bound=Callable[..., Any]) + + +def retry( + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 60.0, + exponential_base: float = 2.0, + exceptions: tuple[type[Exception], ...] = (Exception,), +) -> Callable[[F], F]: + """ + Decorator that retries a function with exponential backoff on failure. + + This decorator is useful for handling flaky API calls or network operations + that may fail temporarily. It implements exponential backoff to avoid + overwhelming the service with rapid retry attempts. + + Args: + max_retries: Maximum number of retry attempts (default: 3). + base_delay: Initial delay between retries in seconds (default: 1.0). + max_delay: Maximum delay between retries in seconds (default: 60.0). + exponential_base: Base for exponential backoff calculation (default: 2.0). + exceptions: Tuple of exception types to catch and retry (default: (Exception,)). + + Returns: + Decorated function that implements retry logic with exponential backoff. + + Example: + >>> @retry(max_retries=3, base_delay=1.0, exceptions=(ConnectionError,)) + ... def fetch_data(): + ... # API call that might fail + ... return api.get_data() + + >>> @retry(max_retries=5, base_delay=2.0, max_delay=30.0) + ... def process_request(): + ... # Processing that might need retries + ... return process() + """ + + def decorator(func: F) -> F: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + last_exception = None + + 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__} failed after {max_retries} retries. " + f"Last error: {e}" + ) + raise + + # Calculate delay with exponential backoff + delay = min(base_delay * (exponential_base**attempt), max_delay) + + logger.warning( + f"Function {func.__name__} failed on attempt {attempt + 1}/{max_retries + 1}. " + f"Retrying in {delay:.2f}s. Error: {e}" + ) + + time.sleep(delay) + + # This should never be reached, but satisfies type checker + if last_exception: + raise last_exception + + return cast(F, wrapper) + + return decorator From bb4d2c425a1bd36e5eb47d60650619faba4a57e7 Mon Sep 17 00:00:00 2001 From: Aditya <97450298+1234-ad@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:35:59 +0530 Subject: [PATCH 2/4] Export retry decorator from core module Add retry decorator to core module exports for easy access --- src/agentunit/core/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/agentunit/core/__init__.py b/src/agentunit/core/__init__.py index 757d228..b7b0233 100644 --- a/src/agentunit/core/__init__.py +++ b/src/agentunit/core/__init__.py @@ -7,6 +7,15 @@ from .runner import Runner, run_suite from .scenario import Scenario +from .utils import retry -__all__ = ["DatasetCase", "DatasetSource", "Runner", "Scenario", "ScenarioResult", "run_suite"] +__all__ = [ + "DatasetCase", + "DatasetSource", + "Runner", + "Scenario", + "ScenarioResult", + "retry", + "run_suite", +] From d18852a3f279fa8ee11b6d7cc5fe339c04af0491 Mon Sep 17 00:00:00 2001 From: Aditya <97450298+1234-ad@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:36:32 +0530 Subject: [PATCH 3/4] Add comprehensive tests for retry decorator Implements thorough test coverage for the retry decorator including: - Successful calls without retries - Retry behavior on exceptions - Exponential backoff verification - Max delay capping - Exception type filtering - Argument preservation - Metadata preservation - Logging verification - Real-world API scenario Addresses issue #27 --- tests/test_retry_decorator.py | 194 ++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/test_retry_decorator.py diff --git a/tests/test_retry_decorator.py b/tests/test_retry_decorator.py new file mode 100644 index 0000000..e4d5254 --- /dev/null +++ b/tests/test_retry_decorator.py @@ -0,0 +1,194 @@ +""" +Tests for the retry decorator with exponential backoff. +""" + +import time +from unittest.mock import Mock, patch + +import pytest + +from agentunit.core.utils import retry + + +class TestRetryDecorator: + """Test suite for the retry decorator.""" + + def test_successful_call_no_retry(self): + """Test that successful calls don't trigger retries.""" + mock_func = Mock(return_value="success") + decorated = retry()(mock_func) + + result = decorated() + + assert result == "success" + assert mock_func.call_count == 1 + + def test_retry_on_exception(self): + """Test that function retries on exception.""" + mock_func = Mock(side_effect=[ValueError("error"), "success"]) + decorated = retry(max_retries=2, base_delay=0.01)(mock_func) + + result = decorated() + + assert result == "success" + assert mock_func.call_count == 2 + + def test_max_retries_exceeded(self): + """Test that exception is raised after max retries.""" + mock_func = Mock(side_effect=ValueError("persistent error")) + decorated = retry(max_retries=2, base_delay=0.01)(mock_func) + + with pytest.raises(ValueError, match="persistent error"): + decorated() + + assert mock_func.call_count == 3 # Initial + 2 retries + + def test_exponential_backoff(self): + """Test that delays follow exponential backoff pattern.""" + mock_func = Mock(side_effect=[ValueError(), ValueError(), "success"]) + + with patch("time.sleep") as mock_sleep: + decorated = retry( + max_retries=3, + base_delay=1.0, + exponential_base=2.0 + )(mock_func) + + result = decorated() + + assert result == "success" + assert mock_func.call_count == 3 + + # Check exponential backoff: 1.0, 2.0 + calls = mock_sleep.call_args_list + assert len(calls) == 2 + assert calls[0][0][0] == 1.0 # First retry: base_delay * 2^0 + assert calls[1][0][0] == 2.0 # Second retry: base_delay * 2^1 + + def test_max_delay_cap(self): + """Test that delay is capped at max_delay.""" + mock_func = Mock(side_effect=[ValueError(), ValueError(), "success"]) + + with patch("time.sleep") as mock_sleep: + decorated = retry( + max_retries=3, + base_delay=10.0, + max_delay=15.0, + exponential_base=2.0 + )(mock_func) + + result = decorated() + + assert result == "success" + + # Check that delays are capped + calls = mock_sleep.call_args_list + assert len(calls) == 2 + assert calls[0][0][0] == 10.0 # First retry: 10.0 + assert calls[1][0][0] == 15.0 # Second retry: capped at max_delay + + def test_specific_exceptions_only(self): + """Test that only specified exceptions trigger retries.""" + mock_func = Mock(side_effect=TypeError("wrong type")) + decorated = retry( + max_retries=2, + base_delay=0.01, + exceptions=(ValueError,) + )(mock_func) + + # TypeError should not be caught, so it raises immediately + with pytest.raises(TypeError, match="wrong type"): + decorated() + + assert mock_func.call_count == 1 # No retries + + def test_multiple_exception_types(self): + """Test retry with multiple exception types.""" + mock_func = Mock(side_effect=[ValueError(), ConnectionError(), "success"]) + decorated = retry( + max_retries=3, + base_delay=0.01, + exceptions=(ValueError, ConnectionError) + )(mock_func) + + result = decorated() + + assert result == "success" + assert mock_func.call_count == 3 + + def test_function_with_arguments(self): + """Test that decorated function preserves arguments.""" + mock_func = Mock(return_value="result") + decorated = retry()(mock_func) + + result = decorated("arg1", "arg2", kwarg1="value1") + + assert result == "result" + mock_func.assert_called_once_with("arg1", "arg2", kwarg1="value1") + + def test_function_metadata_preserved(self): + """Test that function metadata is preserved by decorator.""" + def sample_function(): + """Sample docstring.""" + pass + + decorated = retry()(sample_function) + + assert decorated.__name__ == "sample_function" + assert decorated.__doc__ == "Sample docstring." + + def test_zero_retries(self): + """Test behavior with max_retries=0.""" + mock_func = Mock(side_effect=ValueError("error")) + decorated = retry(max_retries=0, base_delay=0.01)(mock_func) + + with pytest.raises(ValueError, match="error"): + decorated() + + assert mock_func.call_count == 1 # Only initial call, no retries + + @patch("agentunit.core.utils.logger") + def test_logging_on_retry(self, mock_logger): + """Test that retries are logged appropriately.""" + mock_func = Mock(side_effect=[ValueError("error"), "success"]) + decorated = retry(max_retries=2, base_delay=0.01)(mock_func) + + result = decorated() + + assert result == "success" + # Check that warning was logged + assert mock_logger.warning.called + + @patch("agentunit.core.utils.logger") + def test_logging_on_final_failure(self, mock_logger): + """Test that final failure is logged as error.""" + mock_func = Mock(side_effect=ValueError("persistent error")) + decorated = retry(max_retries=1, base_delay=0.01)(mock_func) + + with pytest.raises(ValueError): + decorated() + + # Check that error was logged + assert mock_logger.error.called + + def test_real_world_api_scenario(self): + """Test a realistic API call scenario.""" + call_count = 0 + + def flaky_api_call(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionError("Network timeout") + return {"status": "success", "data": [1, 2, 3]} + + decorated = retry( + max_retries=5, + base_delay=0.01, + exceptions=(ConnectionError,) + )(flaky_api_call) + + result = decorated() + + assert result == {"status": "success", "data": [1, 2, 3]} + assert call_count == 3 From f4d01689b75fd81bb96f9d7cf124f6f9db7e78a1 Mon Sep 17 00:00:00 2001 From: Aditya <97450298+1234-ad@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:37:02 +0530 Subject: [PATCH 4/4] Add example usage for retry decorator Provides comprehensive examples demonstrating: - Basic retry usage with defaults - Custom retry configurations - Quick retries for fast operations - Specific exception handling - Integration with adapter classes Addresses issue #27 --- examples/retry_decorator_usage.py | 172 ++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 examples/retry_decorator_usage.py diff --git a/examples/retry_decorator_usage.py b/examples/retry_decorator_usage.py new file mode 100644 index 0000000..7fc6b60 --- /dev/null +++ b/examples/retry_decorator_usage.py @@ -0,0 +1,172 @@ +""" +Example usage of the retry decorator with exponential backoff. + +This example demonstrates how to use the @retry decorator to handle +flaky API calls and network operations gracefully. +""" + +import random +from agentunit.core import retry + + +# Example 1: Basic retry with default settings +@retry() +def fetch_user_data(user_id: int) -> dict: + """ + Fetch user data from an API with automatic retries. + + Uses default settings: + - max_retries=3 + - base_delay=1.0s + - exponential_base=2.0 + """ + # Simulated API call that might fail + if random.random() < 0.3: # 30% chance of failure + raise ConnectionError("Network timeout") + + return {"id": user_id, "name": "John Doe", "email": "john@example.com"} + + +# Example 2: Custom retry configuration for critical operations +@retry( + max_retries=5, + base_delay=2.0, + max_delay=30.0, + exceptions=(ConnectionError, TimeoutError) +) +def critical_database_operation(query: str) -> list: + """ + Execute a critical database query with aggressive retry policy. + + Configuration: + - Retries up to 5 times + - Starts with 2s delay + - Caps delay at 30s + - Only retries on connection/timeout errors + """ + # Simulated database operation + if random.random() < 0.4: # 40% chance of failure + raise ConnectionError("Database connection lost") + + return [{"result": "data"}] + + +# Example 3: Quick retries for fast operations +@retry( + max_retries=10, + base_delay=0.1, + max_delay=5.0, + exponential_base=1.5 +) +def quick_cache_lookup(key: str) -> str: + """ + Look up value in cache with quick retries. + + Configuration: + - Many retries (10) with short delays + - Starts with 0.1s delay + - Slower exponential growth (1.5x) + - Caps at 5s + """ + # Simulated cache lookup + if random.random() < 0.2: # 20% chance of failure + raise ConnectionError("Cache server unavailable") + + return f"value_for_{key}" + + +# Example 4: Specific exception handling +@retry( + max_retries=3, + base_delay=1.0, + exceptions=(ValueError, KeyError) +) +def parse_api_response(response: dict) -> dict: + """ + Parse API response with retries only for specific errors. + + Only retries on ValueError and KeyError. + Other exceptions (like TypeError) will raise immediately. + """ + # Simulated parsing that might fail + if random.random() < 0.3: + raise ValueError("Invalid response format") + + return {"parsed": True, "data": response} + + +# Example 5: Using retry in adapter classes +class APIAdapter: + """Example adapter class using retry decorator.""" + + @retry(max_retries=3, base_delay=1.0, exceptions=(ConnectionError,)) + def call_llm(self, prompt: str) -> str: + """ + Call LLM API with automatic retries on connection errors. + """ + # Simulated LLM API call + if random.random() < 0.25: + raise ConnectionError("LLM API unavailable") + + return f"Response to: {prompt}" + + @retry(max_retries=5, base_delay=2.0, max_delay=60.0) + def fetch_embeddings(self, texts: list[str]) -> list[list[float]]: + """ + Fetch embeddings with more aggressive retry policy. + """ + # Simulated embedding API call + if random.random() < 0.3: + raise ConnectionError("Embedding service timeout") + + return [[0.1, 0.2, 0.3] for _ in texts] + + +def main(): + """Demonstrate retry decorator usage.""" + + print("Example 1: Basic retry") + try: + user = fetch_user_data(123) + print(f"✓ Fetched user: {user}") + except ConnectionError as e: + print(f"✗ Failed after retries: {e}") + + print("\nExample 2: Critical operation with custom config") + try: + results = critical_database_operation("SELECT * FROM users") + print(f"✓ Query succeeded: {results}") + except ConnectionError as e: + print(f"✗ Failed after retries: {e}") + + print("\nExample 3: Quick cache lookup") + try: + value = quick_cache_lookup("user:123") + print(f"✓ Cache hit: {value}") + except ConnectionError as e: + print(f"✗ Cache unavailable: {e}") + + print("\nExample 4: Specific exception handling") + try: + parsed = parse_api_response({"status": "ok"}) + print(f"✓ Parsed response: {parsed}") + except ValueError as e: + print(f"✗ Parse failed: {e}") + + print("\nExample 5: Adapter class usage") + adapter = APIAdapter() + try: + response = adapter.call_llm("What is AI?") + print(f"✓ LLM response: {response}") + except ConnectionError as e: + print(f"✗ LLM call failed: {e}") + + try: + embeddings = adapter.fetch_embeddings(["hello", "world"]) + print(f"✓ Got embeddings: {len(embeddings)} vectors") + except ConnectionError as e: + print(f"✗ Embedding fetch failed: {e}") + + +if __name__ == "__main__": + main()