b5546ea7bd
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.
242 lines
8.9 KiB
Python
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
|