Files
leocrm/tests/test_error_handling.py
T
Agent Zero b5546ea7bd feat(B.13-B.17+B.16): Error-Handling-Infra, Observability, Graceful Shutdown, Cost-Cap, API Versioning
B.13 Error-Handling-Infrastruktur:
- ErrorCategory Enum (TRANSIENT/PERMANENT/PARTIAL), ApiError erweitert
- Einheitliches Error-Response-Format: {code, detail, field, trace_id, retryable, category}
- 3 FastAPI Exception-Handler (ApiError, HTTPException, unhandled)
- classify_exception() Helper, 6 neue Error-Codes
- 28 Tests in test_error_handling.py

B.14 Observability & trace_id-Korrelation:
- trace_id pro Request (UUID4 short) in structlog contextvars
- X-Trace-Id Response-Header
- Sensitive Fields structlog processor
- llm_complete()/llm_embed() akzeptieren trace_id kwarg
- 12 Tests in test_observability.py

B.15 Graceful Shutdown & Connection Draining:
- _shutdown_event + _inflight_requests Tracking in main.py
- drain_all_connections() in ws_helpers.py
- Worker on_shutdown pausiert WorkflowInstances (status=paused)
- 8 Tests in test_graceful_shutdown.py

B.16 API Versioning Strategie:
- Plugin-Dev-Guide Kapitel 30: URL-basiertes Versioning, Breaking Change Prozess

B.17 Cost Overrun Protection:
- llm_monthly_budget_usd + llm_hard_cutoff Settings
- _check_tenant_budget() vor jedem LLM-Call
- _track_tenant_cost() in Redis (INCRBYFLOAT)
- _check_cost_alerts() bei 50%/80%/100% -> post_system_message()
- 20 Tests in test_cost_protection.py

Total: 68 neue Tests, alle grün. Keine Regressionen.
2026-08-13 21:33:14 +02:00

242 lines
8.9 KiB
Python

"""Tests for unified error handling infrastructure (B.13).
Covers:
- ApiError with code/category/retryable → correct response format
- HTTPException → unified error format
- Unhandled Exception → 500 with trace_id
- All defined error codes have correct fields
- classify_exception: transient/permanent/partial
"""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from unittest.mock import AsyncMock, MagicMock, patch
from app.core.error_codes import (
ApiError,
ErrorCategory,
ERROR_CODES,
build_error_response,
classify_exception,
)
class TestApiError:
"""Tests for ApiError exception class."""
def test_api_error_basic(self):
err = ApiError("not_found")
assert err.code == "not_found"
assert err.status == 404
assert err.detail == "Resource not found"
assert err.field is None
assert err.category == ErrorCategory.PERMANENT
assert err.retryable is False
def test_api_error_with_custom_detail(self):
err = ApiError("validation_error", detail="Email is required", field="email")
assert err.code == "validation_error"
assert err.detail == "Email is required"
assert err.field == "email"
assert err.status == 422
assert err.category == ErrorCategory.PERMANENT
assert err.retryable is False
def test_api_error_transient(self):
err = ApiError("rate_limited")
assert err.category == ErrorCategory.TRANSIENT
assert err.retryable is True
assert err.status == 429
def test_api_error_with_explicit_category(self):
err = ApiError("internal_error", category=ErrorCategory.PERMANENT, retryable=False)
assert err.category == ErrorCategory.PERMANENT
assert err.retryable is False
def test_api_error_to_response(self):
err = ApiError("not_found", detail="Contact not found", field="id")
resp = err.to_response(trace_id="abc123")
assert resp["code"] == "not_found"
assert resp["detail"] == "Contact not found"
assert resp["field"] == "id"
assert resp["trace_id"] == "abc123"
assert resp["retryable"] is False
assert resp["category"] == "permanent"
def test_api_error_to_response_no_trace_id(self):
err = ApiError("rate_limited")
resp = err.to_response()
assert resp["trace_id"] is None
assert resp["retryable"] is True
assert resp["category"] == "transient"
class TestErrorCodes:
"""Tests for all defined error codes."""
def test_all_original_codes_exist(self):
expected = {
"not_found", "permission_denied", "validation_error",
"rate_limited", "internal_error", "service_unavailable",
}
assert expected.issubset(ERROR_CODES.keys())
def test_new_codes_exist(self):
new_codes = {
"forbidden", "conflict", "unprocessable",
"not_implemented", "service_timeout", "bad_gateway",
}
assert new_codes.issubset(ERROR_CODES.keys())
def test_all_codes_have_required_fields(self):
for code, meta in ERROR_CODES.items():
assert "status" in meta, f"{code} missing status"
assert "message" in meta, f"{code} missing message"
assert "category" in meta, f"{code} missing category"
assert "retryable" in meta, f"{code} missing retryable"
assert isinstance(meta["category"], ErrorCategory)
assert isinstance(meta["retryable"], bool)
assert isinstance(meta["status"], int)
assert 100 <= meta["status"] < 600
def test_forbidden_code(self):
meta = ERROR_CODES["forbidden"]
assert meta["status"] == 403
assert meta["category"] == ErrorCategory.PERMANENT
assert meta["retryable"] is False
def test_conflict_code(self):
meta = ERROR_CODES["conflict"]
assert meta["status"] == 409
assert meta["category"] == ErrorCategory.PERMANENT
def test_service_timeout_code(self):
meta = ERROR_CODES["service_timeout"]
assert meta["status"] == 504
assert meta["category"] == ErrorCategory.TRANSIENT
assert meta["retryable"] is True
def test_bad_gateway_code(self):
meta = ERROR_CODES["bad_gateway"]
assert meta["status"] == 502
assert meta["category"] == ErrorCategory.TRANSIENT
assert meta["retryable"] is True
class TestClassifyException:
"""Tests for classify_exception helper."""
def test_classify_timeout_transient(self):
exc = TimeoutError("Request timed out")
assert classify_exception(exc) == ErrorCategory.TRANSIENT
def test_classify_rate_limit_transient(self):
exc = Exception("rate limit exceeded")
assert classify_exception(exc) == ErrorCategory.TRANSIENT
def test_classify_connection_error_transient(self):
exc = ConnectionError("connection reset")
assert classify_exception(exc) == ErrorCategory.TRANSIENT
def test_classify_auth_permanent(self):
exc = Exception("authentication failed")
assert classify_exception(exc) == ErrorCategory.PERMANENT
def test_classify_validation_permanent(self):
exc = Exception("validation error: invalid input")
assert classify_exception(exc) == ErrorCategory.PERMANENT
def test_classify_permission_permanent(self):
exc = Exception("forbidden: permission denied")
assert classify_exception(exc) == ErrorCategory.PERMANENT
def test_classify_not_found_permanent(self):
exc = Exception("not found")
assert classify_exception(exc) == ErrorCategory.PERMANENT
def test_classify_partial_batch(self):
exc = Exception("batch operation partially failed")
assert classify_exception(exc) == ErrorCategory.PARTIAL
def test_classify_partial_bulk(self):
exc = Exception("bulk import: some failed")
assert classify_exception(exc) == ErrorCategory.PARTIAL
def test_classify_api_error_uses_own_category(self):
err = ApiError("rate_limited")
assert classify_exception(err) == ErrorCategory.TRANSIENT
def test_classify_api_error_permanent(self):
err = ApiError("not_found")
assert classify_exception(err) == ErrorCategory.PERMANENT
def test_classify_unknown_defaults_transient(self):
exc = Exception("some unknown error")
assert classify_exception(exc) == ErrorCategory.TRANSIENT
class TestBuildErrorResponse:
"""Tests for build_error_response helper."""
def test_build_error_response_basic(self):
resp = build_error_response("not_found", trace_id="xyz789")
assert resp["code"] == "not_found"
assert resp["detail"] == "Resource not found"
assert resp["trace_id"] == "xyz789"
assert resp["retryable"] is False
assert resp["category"] == "permanent"
def test_build_error_response_with_detail(self):
resp = build_error_response("validation_error", detail="Bad input")
assert resp["detail"] == "Bad input"
assert resp["category"] == "permanent"
class TestErrorResponsesViaAPI:
"""Integration tests for error response format via HTTP client."""
@pytest.mark.asyncio
async def test_api_error_response_format(self, client):
"""ApiError should return unified format with all fields."""
# Use the /api/v1/errors/trigger endpoint if it exists
# Otherwise we test via a known 404 endpoint
response = await client.get("/api/v1/nonexistent-endpoint")
assert response.status_code in (404, 405)
body = response.json()
# Should have unified format fields
assert "code" in body
assert "detail" in body
assert "trace_id" in body
assert "retryable" in body
assert "category" in body
@pytest.mark.asyncio
async def test_trace_id_in_response_header(self, client):
"""X-Trace-Id header should be present in responses."""
response = await client.get("/api/v1/health")
assert "x-trace-id" in response.headers
trace_id = response.headers["x-trace-id"]
assert len(trace_id) == 8 # 8-char hex
@pytest.mark.asyncio
async def test_health_endpoint_success(self, client):
"""Health endpoint should return 200 and trace_id header."""
response = await client.get("/api/v1/health")
assert response.status_code == 200
assert "x-trace-id" in response.headers
@pytest.mark.asyncio
async def test_404_has_unified_format(self, client):
"""404 responses should have unified error format."""
response = await client.get("/api/v1/contacts/00000000-0000-0000-0000-000000000000")
# Could be 401 (no auth) or 404 — both should have unified format
assert response.status_code in (401, 403, 404)
body = response.json()
assert "code" in body
assert "detail" in body
assert "trace_id" in body
assert "retryable" in body
assert "category" in body