"""Tests for retry utilities.""" import asyncio import time from unittest.mock import MagicMock, patch import pytest from src.exceptions import RateLimitError, TranslationError from src.utils.retry import ( RetryConfig, RetryState, async_retry_with_backoff, retry_with_backoff, ) class TestRetryConfig: """Tests for RetryConfig.""" def test_default_values(self): """Test default configuration values.""" config = RetryConfig() assert config.max_attempts == 3 assert config.base_delay == 1.0 assert config.max_delay == 60.0 assert config.exponential_base == 2.0 assert config.jitter is True def test_custom_values(self): """Test custom configuration values.""" config = RetryConfig( max_attempts=5, base_delay=2.0, max_delay=120.0, exponential_base=3.0, jitter=False, ) assert config.max_attempts == 5 assert config.base_delay == 2.0 assert config.max_delay == 120.0 assert config.exponential_base == 3.0 assert config.jitter is False def test_get_delay_exponential(self): """Test exponential delay calculation.""" config = RetryConfig(base_delay=1.0, exponential_base=2.0, jitter=False) assert config.get_delay(0) == 1.0 # 1 * 2^0 = 1 assert config.get_delay(1) == 2.0 # 1 * 2^1 = 2 assert config.get_delay(2) == 4.0 # 1 * 2^2 = 4 assert config.get_delay(3) == 8.0 # 1 * 2^3 = 8 def test_get_delay_max_cap(self): """Test delay is capped at max_delay.""" config = RetryConfig( base_delay=1.0, max_delay=5.0, exponential_base=2.0, jitter=False, ) assert config.get_delay(0) == 1.0 assert config.get_delay(1) == 2.0 assert config.get_delay(2) == 4.0 assert config.get_delay(3) == 5.0 # Capped at max_delay assert config.get_delay(10) == 5.0 def test_get_delay_with_jitter(self): """Test delay includes jitter when enabled.""" config = RetryConfig(base_delay=10.0, jitter=True) delays = [config.get_delay(0) for _ in range(100)] # With jitter, delays should vary assert len(set(delays)) > 1 # All delays should be within +/- 25% of base for delay in delays: assert 7.5 <= delay <= 12.5 def test_get_delay_never_negative(self): """Test delay is never negative.""" config = RetryConfig(base_delay=0.1, jitter=True) for _ in range(100): assert config.get_delay(0) >= 0 class TestRetryState: """Tests for RetryState.""" def test_initial_state(self): """Test initial retry state.""" state = RetryState() assert state.attempt == 0 assert state.last_exception is None def test_should_retry_retryable(self): """Test should_retry returns True for retryable exceptions.""" state = RetryState() error = RateLimitError("Rate limit") assert state.should_retry(error) is True assert state.last_exception is error def test_should_retry_non_retryable(self): """Test should_retry returns False for non-retryable exceptions.""" state = RetryState() error = ValueError("Not retryable") assert state.should_retry(error) is False def test_should_retry_max_attempts_reached(self): """Test should_retry returns False when max attempts reached.""" config = RetryConfig(max_attempts=2) state = RetryState(config) error = RateLimitError("Rate limit") assert state.should_retry(error) is True state.attempt = 1 # After first wait assert state.should_retry(error) is False def test_wait_increments_attempt(self): """Test wait increments attempt counter.""" config = RetryConfig(base_delay=0.001, jitter=False) state = RetryState(config) state.should_retry(RateLimitError("test")) assert state.attempt == 0 state.wait() assert state.attempt == 1 def test_wait_uses_rate_limit_retry_after(self): """Test wait uses retry_after from RateLimitError.""" config = RetryConfig(base_delay=10.0, jitter=False) state = RetryState(config) error = RateLimitError("Rate limit", retry_after=0.001) state.should_retry(error) start = time.time() state.wait() elapsed = time.time() - start # Should use retry_after (0.001s) not base_delay (10s) assert elapsed < 1.0 def test_reset(self): """Test reset clears state.""" state = RetryState() state.attempt = 5 state.last_exception = RateLimitError("test") state.reset() assert state.attempt == 0 assert state.last_exception is None @pytest.mark.asyncio async def test_async_wait(self): """Test async_wait works correctly.""" config = RetryConfig(base_delay=0.001, jitter=False) state = RetryState(config) state.should_retry(RateLimitError("test")) assert state.attempt == 0 await state.async_wait() assert state.attempt == 1 class TestRetryWithBackoff: """Tests for retry_with_backoff decorator.""" def test_success_no_retry(self): """Test successful call doesn't retry.""" call_count = 0 @retry_with_backoff(RetryConfig(max_attempts=3)) def successful_func(): nonlocal call_count call_count += 1 return "success" result = successful_func() assert result == "success" assert call_count == 1 def test_retry_on_retryable_error(self): """Test retry on retryable error.""" call_count = 0 config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(RateLimitError,), ) @retry_with_backoff(config) def failing_then_success(): nonlocal call_count call_count += 1 if call_count < 3: raise RateLimitError("Rate limit") return "success" result = failing_then_success() assert result == "success" assert call_count == 3 def test_no_retry_on_non_retryable_error(self): """Test no retry on non-retryable error.""" call_count = 0 config = RetryConfig( max_attempts=3, retryable_exceptions=(RateLimitError,), ) @retry_with_backoff(config) def failing_func(): nonlocal call_count call_count += 1 raise ValueError("Not retryable") with pytest.raises(ValueError): failing_func() assert call_count == 1 def test_exhausts_retries(self): """Test raises after exhausting retries.""" call_count = 0 config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(RateLimitError,), ) @retry_with_backoff(config) def always_failing(): nonlocal call_count call_count += 1 raise RateLimitError("Always fails") with pytest.raises(RateLimitError): always_failing() assert call_count == 3 def test_on_retry_callback(self): """Test on_retry callback is called.""" retry_calls = [] config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(RateLimitError,), ) def on_retry(exc, attempt): retry_calls.append((exc, attempt)) @retry_with_backoff(config, on_retry=on_retry) def failing_then_success(): if len(retry_calls) < 2: raise RateLimitError("Rate limit") return "success" result = failing_then_success() assert result == "success" assert len(retry_calls) == 2 assert retry_calls[0][1] == 0 assert retry_calls[1][1] == 1 def test_default_config(self): """Test decorator works with default config.""" call_count = 0 @retry_with_backoff() def successful_func(): nonlocal call_count call_count += 1 return "success" result = successful_func() assert result == "success" assert call_count == 1 class TestAsyncRetryWithBackoff: """Tests for async_retry_with_backoff decorator.""" @pytest.mark.asyncio async def test_success_no_retry(self): """Test successful async call doesn't retry.""" call_count = 0 @async_retry_with_backoff(RetryConfig(max_attempts=3)) async def successful_func(): nonlocal call_count call_count += 1 return "success" result = await successful_func() assert result == "success" assert call_count == 1 @pytest.mark.asyncio async def test_retry_on_retryable_error(self): """Test retry on retryable error.""" call_count = 0 config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(RateLimitError,), ) @async_retry_with_backoff(config) async def failing_then_success(): nonlocal call_count call_count += 1 if call_count < 3: raise RateLimitError("Rate limit") return "success" result = await failing_then_success() assert result == "success" assert call_count == 3 @pytest.mark.asyncio async def test_no_retry_on_non_retryable_error(self): """Test no retry on non-retryable error.""" call_count = 0 config = RetryConfig( max_attempts=3, retryable_exceptions=(RateLimitError,), ) @async_retry_with_backoff(config) async def failing_func(): nonlocal call_count call_count += 1 raise ValueError("Not retryable") with pytest.raises(ValueError): await failing_func() assert call_count == 1 @pytest.mark.asyncio async def test_exhausts_retries(self): """Test raises after exhausting retries.""" call_count = 0 config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(RateLimitError,), ) @async_retry_with_backoff(config) async def always_failing(): nonlocal call_count call_count += 1 raise RateLimitError("Always fails") with pytest.raises(RateLimitError): await always_failing() assert call_count == 3