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
176 lines
6.9 KiB
Python
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]
|