Spaces:
Sleeping
Sleeping
| """ | |
| Tests for retry_with_backoff (utils.py) | |
| All tests are pure unit tests β no real API calls are made. | |
| APIError is faked via a real subclass so isinstance() checks inside utils.py pass. | |
| """ | |
| import pytest | |
| import sys | |
| import os | |
| from unittest.mock import patch, MagicMock | |
| sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../app"))) | |
| from google.genai.errors import APIError | |
| from utils import retry_with_backoff | |
| class FakeAPIError(APIError): | |
| """ | |
| A real subclass of APIError so isinstance(err, APIError) returns True. | |
| APIError.__init__ requires a specific signature we bypass here. | |
| """ | |
| def __init__(self, code: int): | |
| # Skip the parent __init__ to avoid needing a full Response object | |
| self.code = code | |
| self.message = f"Fake API error {code}" | |
| def __str__(self): | |
| return self.message | |
| def _make_api_error(code: int) -> FakeAPIError: | |
| """Return a real APIError subclass instance with the given HTTP status code.""" | |
| return FakeAPIError(code) | |
| class TestRetryWithBackoff: | |
| # ββ Success path ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_returns_value_on_first_success(self): | |
| func = MagicMock(return_value="ok") | |
| result = retry_with_backoff(func, max_retries=3, initial_delay=0) | |
| assert result == "ok" | |
| func.assert_called_once() | |
| # ββ Retry on retriable codes βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_retries_on_429_then_succeeds(self, mock_sleep): | |
| err = _make_api_error(429) | |
| func = MagicMock(side_effect=[err, err, "recovered"]) | |
| result = retry_with_backoff(func, max_retries=5, initial_delay=0, backoff_factor=2) | |
| assert result == "recovered" | |
| assert func.call_count == 3 | |
| def test_retries_on_503_then_succeeds(self, mock_sleep): | |
| err = _make_api_error(503) | |
| func = MagicMock(side_effect=[err, "ok"]) | |
| result = retry_with_backoff(func, max_retries=3, initial_delay=0) | |
| assert result == "ok" | |
| assert func.call_count == 2 | |
| # ββ Exhausted retries βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_raises_after_max_retries_exhausted(self, mock_sleep): | |
| err = _make_api_error(429) | |
| func = MagicMock(side_effect=err) | |
| with pytest.raises(FakeAPIError): | |
| retry_with_backoff(func, max_retries=3, initial_delay=0) | |
| assert func.call_count == 3 | |
| # ββ Non-retriable codes raise immediately βββββββββββββββββββββββββββββββββ | |
| def test_raises_immediately_on_non_retriable_code(self, mock_sleep): | |
| err = _make_api_error(400) # Bad request β not retriable | |
| func = MagicMock(side_effect=err) | |
| with pytest.raises(FakeAPIError): | |
| retry_with_backoff(func, max_retries=5, initial_delay=0) | |
| # Should not retry β called exactly once | |
| assert func.call_count == 1 | |
| def test_raises_immediately_on_401(self, mock_sleep): | |
| err = _make_api_error(401) | |
| func = MagicMock(side_effect=err) | |
| with pytest.raises(FakeAPIError): | |
| retry_with_backoff(func, max_retries=5, initial_delay=0) | |
| assert func.call_count == 1 | |
| # ββ Non-API exceptions propagate immediately ββββββββββββββββββββββββββββββ | |
| def test_non_api_exception_propagates_immediately(self): | |
| func = MagicMock(side_effect=ValueError("bad input")) | |
| with pytest.raises(ValueError, match="bad input"): | |
| retry_with_backoff(func, max_retries=5, initial_delay=0) | |
| func.assert_called_once() | |
| def test_runtime_error_propagates_immediately(self): | |
| func = MagicMock(side_effect=RuntimeError("crash")) | |
| with pytest.raises(RuntimeError): | |
| retry_with_backoff(func, max_retries=5, initial_delay=0) | |
| func.assert_called_once() | |
| # ββ Sleep is actually called between retries βββββββββββββββββββββββββββββββ | |
| def test_sleep_is_called_between_retries(self, mock_sleep): | |
| err = _make_api_error(429) | |
| func = MagicMock(side_effect=[err, err, "ok"]) | |
| retry_with_backoff(func, max_retries=5, initial_delay=1, backoff_factor=2) | |
| # Should sleep twice (after attempt 1 and attempt 2) | |
| assert mock_sleep.call_count == 2 | |
| # ββ Backoff delay grows with each retry βββββββββββββββββββββββββββββββββββ | |
| # Remove jitter noise | |
| def test_delay_doubles_with_backoff_factor(self, mock_sleep, mock_random): | |
| err = _make_api_error(429) | |
| func = MagicMock(side_effect=[err, err, err, "ok"]) | |
| retry_with_backoff(func, max_retries=5, initial_delay=1, backoff_factor=2) | |
| sleep_calls = [c.args[0] for c in mock_sleep.call_args_list] | |
| # Delays should be 1, 2, 4 (doubling each time, jitter zeroed out) | |
| assert sleep_calls[0] == pytest.approx(1.0, abs=0.01) | |
| assert sleep_calls[1] == pytest.approx(2.0, abs=0.01) | |
| assert sleep_calls[2] == pytest.approx(4.0, abs=0.01) | |