import pytest from pytest_mock import MockerFixture from app.dsp.exceptions import RateLimitError, StreamingAPIError from app.dsp.guard import RETRY_AFTER_THRESHOLD, RateLimitGuard class TestRateLimitGuard: def test_returns_result_on_success(self) -> None: guard = RateLimitGuard() result = guard.execute(lambda: 42) assert result == 42 def test_stats_after_success(self) -> None: guard = RateLimitGuard() guard.execute(lambda: None) assert guard.stats.requests == 1 assert guard.stats.retries == 0 assert guard.stats.rate_limited == 0 def test_accumulates_requests_across_calls(self) -> None: guard = RateLimitGuard() guard.execute(lambda: None) guard.execute(lambda: None) guard.execute(lambda: None) assert guard.stats.requests == 3 assert guard.stats.total_attempts == 3 def test_propagates_non_429_error(self, mocker: MockerFixture) -> None: guard = RateLimitGuard() mocker.patch("app.dsp.guard.time.sleep") def fn() -> None: raise StreamingAPIError("server error", status_code=500) with pytest.raises(StreamingAPIError, match="server error"): guard.execute(fn) assert guard.stats.requests == 1 assert guard.stats.rate_limited == 0 assert guard.stats.retries == 0 def test_retries_on_429_below_threshold(self, mocker: MockerFixture) -> None: guard = RateLimitGuard(max_retries=2) mock_sleep = mocker.patch("app.dsp.guard.time.sleep") calls = 0 def fn() -> str: nonlocal calls calls += 1 if calls < 3: raise StreamingAPIError("rate limited", status_code=429, retry_after=1) return "ok" result = guard.execute(fn) assert result == "ok" assert guard.stats.requests == 1 assert guard.stats.retries == 2 assert guard.stats.rate_limited == 2 assert mock_sleep.call_count == 2 def test_raises_immediately_when_retry_after_exceeds_threshold( self, mocker: MockerFixture ) -> None: guard = RateLimitGuard() mock_sleep = mocker.patch("app.dsp.guard.time.sleep") def fn() -> None: raise StreamingAPIError( "rate limited", status_code=429, retry_after=RETRY_AFTER_THRESHOLD + 1 ) with pytest.raises(RateLimitError) as exc_info: guard.execute(fn) assert exc_info.value.retry_after == RETRY_AFTER_THRESHOLD + 1 assert guard.stats.rate_limited == 1 assert guard.stats.retries == 0 mock_sleep.assert_not_called() def test_raises_after_max_retries_exhausted(self, mocker: MockerFixture) -> None: guard = RateLimitGuard(max_retries=2) mocker.patch("app.dsp.guard.time.sleep") def fn() -> None: raise StreamingAPIError("rate limited", status_code=429, retry_after=1) with pytest.raises(RateLimitError, match="retries exhausted"): guard.execute(fn) assert guard.stats.requests == 1 assert guard.stats.retries == 2 assert guard.stats.rate_limited == 3 def test_uses_default_retry_after_when_not_set(self, mocker: MockerFixture) -> None: guard = RateLimitGuard(max_retries=1, default_retry_after=3) mock_sleep = mocker.patch("app.dsp.guard.time.sleep") mocker.patch("app.dsp.guard.random.uniform", return_value=0.0) calls = 0 def fn() -> str: nonlocal calls calls += 1 if calls == 1: raise StreamingAPIError( "rate limited", status_code=429, retry_after=None ) return "ok" guard.execute(fn) mock_sleep.assert_called_once_with(3.0) def test_rps_is_none_for_single_request(self) -> None: guard = RateLimitGuard() guard.execute(lambda: None) assert guard.stats.rps is None def test_rps_is_set_after_multiple_requests(self) -> None: guard = RateLimitGuard() for _ in range(10): guard.execute(lambda: None) assert guard.stats.rps is not None assert guard.stats.rps > 0