ForcePilot/backend/test/unit/channels/test_wechat_retry.py

144 lines
5.5 KiB
Python
Raw Normal View History

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")