Files
smart-support/backend/tests/unit/test_error_handler.py
Yaojia Wang 0e78e5b06b feat: complete phase 5 -- error hardening, frontend, Docker, demo, docs
Backend:
- ConversationTracker: Protocol + PostgresConversationTracker for lifecycle tracking
- Error handler: ErrorCategory enum, classify_error(), with_retry() exponential backoff
- Wire PostgresAnalyticsRecorder + ConversationTracker into ws_handler
- Rate limiting (10 msg/10s per thread), edge case hardening
- Health endpoint GET /api/health, version 0.5.0
- Demo seed data script + sample OpenAPI spec

Frontend (all new):
- React Router with NavBar (Chat / Replay / Dashboard / Review)
- ReplayListPage + ReplayPage with ReplayTimeline component
- DashboardPage with MetricCard, range selector, zero-state
- ReviewPage for OpenAPI classification review
- ErrorBanner for WebSocket disconnect handling
- API client (api.ts) with typed fetch wrappers

Infrastructure:
- Frontend Dockerfile (multi-stage node -> nginx)
- nginx.conf with SPA routing + API/WS proxy
- docker-compose.yml with frontend service + healthchecks
- .env.example files (root + backend)

Documentation:
- README.md with quick start and architecture
- Agent configuration guide
- OpenAPI import guide
- Deployment guide
- Demo script

48 new tests, 449 total passing, 92.87% coverage
2026-03-31 21:20:06 +02:00

176 lines
6.9 KiB
Python

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