from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock import httpx import pytest from yuxi.channels.adapters.wechat.retry import ( classify_http_error, retry_with_backoff, ) from yuxi.channels.adapters.wechat.retry import NetworkErrorClass class TestClassifyHttpError: def test_rate_limit_429(self): assert classify_http_error(429) == NetworkErrorClass.RATE_LIMIT def test_server_error_500(self): assert classify_http_error(500) == NetworkErrorClass.SERVER_ERROR def test_server_error_502(self): assert classify_http_error(502) == NetworkErrorClass.SERVER_ERROR def test_server_error_503(self): assert classify_http_error(503) == NetworkErrorClass.SERVER_ERROR def test_server_error_504(self): assert classify_http_error(504) == NetworkErrorClass.SERVER_ERROR def test_auth_error_401(self): assert classify_http_error(401) == NetworkErrorClass.AUTH def test_auth_error_403(self): assert classify_http_error(403) == NetworkErrorClass.AUTH def test_fatal_400(self): assert classify_http_error(400) == NetworkErrorClass.FATAL def test_fatal_404(self): assert classify_http_error(404) == NetworkErrorClass.FATAL def test_recoverable_200(self): assert classify_http_error(200) == NetworkErrorClass.RECOVERABLE def test_recoverable_302(self): assert classify_http_error(302) == NetworkErrorClass.RECOVERABLE def test_recoverable_absent_status(self): assert classify_http_error(0) == NetworkErrorClass.RECOVERABLE class TestRetryWithBackoff: @pytest.mark.asyncio async def test_success_on_first_attempt(self): mock_fn = AsyncMock(return_value="success") result = await retry_with_backoff(mock_fn, max_retries=3) assert result == "success" assert mock_fn.call_count == 1 @pytest.mark.asyncio async def test_retry_on_timeout_then_succeed(self): mock_fn = AsyncMock(side_effect=[httpx.TimeoutException("timeout"), "success"]) result = await retry_with_backoff(mock_fn, max_retries=3, base_delay=0.01) assert result == "success" assert mock_fn.call_count == 2 @pytest.mark.asyncio async def test_retry_on_network_error_then_succeed(self): mock_fn = AsyncMock(side_effect=[httpx.NetworkError("network"), "success"]) result = await retry_with_backoff(mock_fn, max_retries=3, base_delay=0.01) assert result == "success" assert mock_fn.call_count == 2 @pytest.mark.asyncio async def test_retry_on_connect_error_then_succeed(self): mock_fn = AsyncMock(side_effect=[httpx.ConnectError("connect"), "success"]) result = await retry_with_backoff(mock_fn, max_retries=3, base_delay=0.01) assert result == "success" assert mock_fn.call_count == 2 @pytest.mark.asyncio async def test_exhaust_retries_on_timeout(self): mock_fn = AsyncMock(side_effect=httpx.TimeoutException("timeout")) with pytest.raises(httpx.TimeoutException): await retry_with_backoff(mock_fn, max_retries=2, base_delay=0.01) assert mock_fn.call_count == 2 @pytest.mark.asyncio async def test_no_retry_on_fatal_http_error(self): response = MagicMock() response.status_code = 400 mock_fn = AsyncMock(side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=response)) with pytest.raises(httpx.HTTPStatusError): await retry_with_backoff(mock_fn, max_retries=3, base_delay=0.01) assert mock_fn.call_count == 1 @pytest.mark.asyncio async def test_no_retry_on_auth_error(self): response = MagicMock() response.status_code = 401 mock_fn = AsyncMock(side_effect=httpx.HTTPStatusError("unauth", request=MagicMock(), response=response)) with pytest.raises(httpx.HTTPStatusError): await retry_with_backoff(mock_fn, max_retries=3, base_delay=0.01) assert mock_fn.call_count == 1 @pytest.mark.asyncio async def test_retry_on_server_error_then_succeed(self): response_500 = MagicMock() response_500.status_code = 500 mock_fn = AsyncMock(side_effect=[ httpx.HTTPStatusError("server error", request=MagicMock(), response=response_500), "success", ]) result = await retry_with_backoff(mock_fn, max_retries=3, base_delay=0.01) assert result == "success" assert mock_fn.call_count == 2 @pytest.mark.asyncio async def test_retry_on_rate_limit_then_succeed(self): response_429 = MagicMock() response_429.status_code = 429 mock_fn = AsyncMock(side_effect=[ httpx.HTTPStatusError("rate limit", request=MagicMock(), response=response_429), "success", ]) result = await retry_with_backoff(mock_fn, max_retries=3, base_delay=0.01) assert result == "success" assert mock_fn.call_count == 2 @pytest.mark.asyncio async def test_non_retryable_exception_raised_immediately(self): mock_fn = AsyncMock(side_effect=ValueError("bad value")) with pytest.raises(ValueError): await retry_with_backoff(mock_fn, max_retries=3, base_delay=0.01) assert mock_fn.call_count == 1 @pytest.mark.asyncio async def test_with_args_and_kwargs(self): mock_fn = AsyncMock(return_value="ok") result = await retry_with_backoff(mock_fn, "arg1", kwarg1="value1") assert result == "ok" mock_fn.assert_called_with("arg1", kwarg1="value1")