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.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user