"""Tests for app.tools.error_handler module.""" from __future__ import annotations from unittest.mock import AsyncMock, patch import httpx import pytest from app.tools.error_handler import ( ErrorCategory, classify_error, with_retry, ) pytestmark = pytest.mark.unit class TestErrorClassification: def test_timeout_exception_is_timeout(self) -> None: exc = httpx.TimeoutException("timed out") assert classify_error(exc) == ErrorCategory.TIMEOUT def test_connect_error_is_network(self) -> None: exc = httpx.ConnectError("connection refused") assert classify_error(exc) == ErrorCategory.NETWORK def test_401_is_auth_failure(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(401, request=request) exc = httpx.HTTPStatusError("401", request=request, response=response) assert classify_error(exc) == ErrorCategory.AUTH_FAILURE def test_403_is_auth_failure(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(403, request=request) exc = httpx.HTTPStatusError("403", request=request, response=response) assert classify_error(exc) == ErrorCategory.AUTH_FAILURE def test_429_is_retryable(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(429, request=request) exc = httpx.HTTPStatusError("429", request=request, response=response) assert classify_error(exc) == ErrorCategory.RETRYABLE def test_500_is_retryable(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(500, request=request) exc = httpx.HTTPStatusError("500", request=request, response=response) assert classify_error(exc) == ErrorCategory.RETRYABLE def test_502_is_retryable(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(502, request=request) exc = httpx.HTTPStatusError("502", request=request, response=response) assert classify_error(exc) == ErrorCategory.RETRYABLE def test_503_is_retryable(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(503, request=request) exc = httpx.HTTPStatusError("503", request=request, response=response) assert classify_error(exc) == ErrorCategory.RETRYABLE def test_404_is_non_retryable(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(404, request=request) exc = httpx.HTTPStatusError("404", request=request, response=response) assert classify_error(exc) == ErrorCategory.NON_RETRYABLE def test_400_is_non_retryable(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(400, request=request) exc = httpx.HTTPStatusError("400", request=request, response=response) assert classify_error(exc) == ErrorCategory.NON_RETRYABLE def test_generic_exception_is_non_retryable(self) -> None: exc = ValueError("bad value") assert classify_error(exc) == ErrorCategory.NON_RETRYABLE def test_runtime_error_is_non_retryable(self) -> None: exc = RuntimeError("boom") assert classify_error(exc) == ErrorCategory.NON_RETRYABLE class TestWithRetry: @pytest.mark.asyncio async def test_succeeds_on_first_try(self) -> None: fn = AsyncMock(return_value="ok") result = await with_retry(fn, max_retries=3, base_delay=0.0) assert result == "ok" assert fn.call_count == 1 @pytest.mark.asyncio async def test_retries_on_retryable_error(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(503, request=request) retryable_exc = httpx.HTTPStatusError("503", request=request, response=response) fn = AsyncMock(side_effect=[retryable_exc, retryable_exc, "success"]) with patch("app.tools.error_handler.asyncio.sleep", new_callable=AsyncMock): result = await with_retry(fn, max_retries=3, base_delay=0.0) assert result == "success" assert fn.call_count == 3 @pytest.mark.asyncio async def test_does_not_retry_non_retryable_error(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(404, request=request) non_retryable_exc = httpx.HTTPStatusError("404", request=request, response=response) fn = AsyncMock(side_effect=non_retryable_exc) with pytest.raises(httpx.HTTPStatusError): await with_retry(fn, max_retries=3, base_delay=0.0) assert fn.call_count == 1 @pytest.mark.asyncio async def test_does_not_retry_auth_failure(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(401, request=request) auth_exc = httpx.HTTPStatusError("401", request=request, response=response) fn = AsyncMock(side_effect=auth_exc) with pytest.raises(httpx.HTTPStatusError): await with_retry(fn, max_retries=3, base_delay=0.0) assert fn.call_count == 1 @pytest.mark.asyncio async def test_raises_after_max_retries_exhausted(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(500, request=request) retryable_exc = httpx.HTTPStatusError("500", request=request, response=response) fn = AsyncMock(side_effect=retryable_exc) with ( patch("app.tools.error_handler.asyncio.sleep", new_callable=AsyncMock), pytest.raises(httpx.HTTPStatusError), ): await with_retry(fn, max_retries=3, base_delay=0.0) assert fn.call_count == 3 @pytest.mark.asyncio async def test_does_not_retry_timeout(self) -> None: """TimeoutException is TIMEOUT category -- not retried by default.""" fn = AsyncMock(side_effect=httpx.TimeoutException("timed out")) with pytest.raises(httpx.TimeoutException): await with_retry(fn, max_retries=3, base_delay=0.0) assert fn.call_count == 1 @pytest.mark.asyncio async def test_exponential_backoff_increases_delay(self) -> None: request = httpx.Request("GET", "http://example.com") response = httpx.Response(503, request=request) retryable_exc = httpx.HTTPStatusError("503", request=request, response=response) fn = AsyncMock(side_effect=[retryable_exc, retryable_exc, "done"]) sleep_delays: list[float] = [] async def capture_sleep(delay: float) -> None: sleep_delays.append(delay) with patch("app.tools.error_handler.asyncio.sleep", side_effect=capture_sleep): await with_retry(fn, max_retries=3, base_delay=1.0) assert len(sleep_delays) == 2 assert sleep_delays[1] > sleep_delays[0]