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,275 @@
|
||||
"""Tests for LLM cost overrun protection (B.17).
|
||||
|
||||
Covers:
|
||||
- Cost cap blocks LLM calls when budget exceeded
|
||||
- Cost tracking in Redis is correct
|
||||
- Alerts at 50%/80%/100% of budget
|
||||
- Hard stop blocks LLM calls
|
||||
- Reset at month boundary (key includes month)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai.llm_client import (
|
||||
_get_cost_key,
|
||||
get_tenant_monthly_cost,
|
||||
_check_tenant_budget,
|
||||
_track_tenant_cost,
|
||||
_check_cost_alerts,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TENANT_ID = uuid.uuid4()
|
||||
|
||||
|
||||
def _mock_redis_with_cost(initial_cost: float = 0.0):
|
||||
"""Create a mock Redis client with cost tracking support."""
|
||||
r = MagicMock()
|
||||
r.get = AsyncMock(return_value=str(initial_cost) if initial_cost > 0 else None)
|
||||
r.incrbyfloat = AsyncMock(return_value=initial_cost)
|
||||
r.expire = AsyncMock()
|
||||
r.set = AsyncMock(return_value=True) # nx=True returns True if key was set
|
||||
return r
|
||||
|
||||
|
||||
def _mock_redis_with_alerted(alerted_thresholds: set[float] | None = None):
|
||||
"""Create a mock Redis where some alert thresholds are already set."""
|
||||
r = MagicMock()
|
||||
r.get = AsyncMock(return_value=None)
|
||||
r.incrbyfloat = AsyncMock(return_value=0.0)
|
||||
r.expire = AsyncMock()
|
||||
alerted = alerted_thresholds or set()
|
||||
|
||||
async def _set_nx(key, value, nx=True, ex=None):
|
||||
# Extract threshold from key: cost:alerted:{tenant_id}:{threshold}:{month}
|
||||
parts = key.split(":")
|
||||
if len(parts) >= 4:
|
||||
try:
|
||||
threshold = float(parts[3])
|
||||
if threshold in alerted:
|
||||
return None # Key already exists
|
||||
except ValueError:
|
||||
pass
|
||||
return True # Key was set (new alert)
|
||||
|
||||
r.set = AsyncMock(side_effect=_set_nx)
|
||||
return r
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Tests
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCostKey:
|
||||
"""Tests for Redis cost key generation."""
|
||||
|
||||
def test_cost_key_format(self):
|
||||
key = _get_cost_key(TENANT_ID)
|
||||
now = datetime.now(timezone.utc)
|
||||
month_str = now.strftime("%Y-%m")
|
||||
assert key == f"cost:tenant:{TENANT_ID}:month:{month_str}"
|
||||
|
||||
def test_cost_key_includes_month(self):
|
||||
key = _get_cost_key(TENANT_ID)
|
||||
now = datetime.now(timezone.utc)
|
||||
assert now.strftime("%Y-%m") in key
|
||||
|
||||
def test_cost_key_with_string_tenant(self):
|
||||
key = _get_cost_key("some-tenant-id")
|
||||
assert "some-tenant-id" in key
|
||||
|
||||
|
||||
class TestGetTenantMonthlyCost:
|
||||
"""Tests for get_tenant_monthly_cost."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cost_returns_zero_when_no_data(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = MagicMock()
|
||||
r.get = AsyncMock(return_value=None)
|
||||
mock_get_redis.return_value = r
|
||||
cost = await get_tenant_monthly_cost(TENANT_ID)
|
||||
assert cost == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cost_returns_stored_value(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = MagicMock()
|
||||
r.get = AsyncMock(return_value="42.50")
|
||||
mock_get_redis.return_value = r
|
||||
cost = await get_tenant_monthly_cost(TENANT_ID)
|
||||
assert cost == 42.50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cost_handles_redis_error(self):
|
||||
with patch("app.core.auth.get_redis", side_effect=Exception("Redis down")):
|
||||
cost = await get_tenant_monthly_cost(TENANT_ID)
|
||||
assert cost == 0.0
|
||||
|
||||
|
||||
class TestCheckTenantBudget:
|
||||
"""Tests for budget enforcement."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_budget_check_passes_when_under_budget(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = MagicMock()
|
||||
r.get = AsyncMock(return_value="10.0")
|
||||
mock_get_redis.return_value = r
|
||||
with patch("app.config.get_settings") as mock_settings:
|
||||
mock_settings.return_value.llm_monthly_budget_usd = 100.0
|
||||
mock_settings.return_value.llm_hard_cutoff = True
|
||||
await _check_tenant_budget(TENANT_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_budget_check_raises_when_exceeded_hard_cutoff(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = MagicMock()
|
||||
r.get = AsyncMock(return_value="100.0")
|
||||
mock_get_redis.return_value = r
|
||||
with patch("app.config.get_settings") as mock_settings:
|
||||
mock_settings.return_value.llm_monthly_budget_usd = 100.0
|
||||
mock_settings.return_value.llm_hard_cutoff = True
|
||||
with pytest.raises(ValueError, match="Tenant LLM budget exceeded"):
|
||||
await _check_tenant_budget(TENANT_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_budget_check_no_raise_when_soft_cutoff(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = MagicMock()
|
||||
r.get = AsyncMock(return_value="150.0")
|
||||
mock_get_redis.return_value = r
|
||||
with patch("app.config.get_settings") as mock_settings:
|
||||
mock_settings.return_value.llm_monthly_budget_usd = 100.0
|
||||
mock_settings.return_value.llm_hard_cutoff = False
|
||||
await _check_tenant_budget(TENANT_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_budget_check_skips_when_no_tenant(self):
|
||||
await _check_tenant_budget(None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_budget_check_skips_when_budget_zero(self):
|
||||
with patch("app.config.get_settings") as mock_settings:
|
||||
mock_settings.return_value.llm_monthly_budget_usd = 0.0
|
||||
mock_settings.return_value.llm_hard_cutoff = True
|
||||
await _check_tenant_budget(TENANT_ID)
|
||||
|
||||
|
||||
class TestTrackTenantCost:
|
||||
"""Tests for cost tracking in Redis."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_cost_increments_redis(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = MagicMock()
|
||||
r.incrbyfloat = AsyncMock(return_value=5.50)
|
||||
r.expire = AsyncMock()
|
||||
mock_get_redis.return_value = r
|
||||
|
||||
with patch("app.ai.llm_client._check_cost_alerts", new_callable=AsyncMock):
|
||||
total = await _track_tenant_cost(TENANT_ID, 5.50)
|
||||
assert total == 5.50
|
||||
r.incrbyfloat.assert_called_once()
|
||||
r.expire.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_cost_skips_zero(self):
|
||||
total = await _track_tenant_cost(TENANT_ID, 0.0)
|
||||
assert total == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_cost_skips_none_tenant(self):
|
||||
total = await _track_tenant_cost(None, 5.0)
|
||||
assert total == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_cost_handles_redis_error(self):
|
||||
with patch("app.core.auth.get_redis", side_effect=Exception("Redis down")):
|
||||
total = await _track_tenant_cost(TENANT_ID, 5.0)
|
||||
assert total == 0.0
|
||||
|
||||
|
||||
class TestCostAlerts:
|
||||
"""Tests for cost alert thresholds."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_at_50_percent(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = _mock_redis_with_alerted()
|
||||
mock_get_redis.return_value = r
|
||||
with patch("app.config.get_settings") as mock_settings:
|
||||
mock_settings.return_value.llm_monthly_budget_usd = 100.0
|
||||
await _check_cost_alerts(TENANT_ID, 50.0, db=None)
|
||||
assert r.set.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_at_80_percent(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = _mock_redis_with_alerted()
|
||||
mock_get_redis.return_value = r
|
||||
with patch("app.config.get_settings") as mock_settings:
|
||||
mock_settings.return_value.llm_monthly_budget_usd = 100.0
|
||||
await _check_cost_alerts(TENANT_ID, 80.0, db=None)
|
||||
assert r.set.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_at_100_percent(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = _mock_redis_with_alerted()
|
||||
mock_get_redis.return_value = r
|
||||
with patch("app.config.get_settings") as mock_settings:
|
||||
mock_settings.return_value.llm_monthly_budget_usd = 100.0
|
||||
await _check_cost_alerts(TENANT_ID, 100.0, db=None)
|
||||
assert r.set.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_not_fired_below_threshold(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = _mock_redis_with_alerted()
|
||||
mock_get_redis.return_value = r
|
||||
with patch("app.config.get_settings") as mock_settings:
|
||||
mock_settings.return_value.llm_monthly_budget_usd = 100.0
|
||||
await _check_cost_alerts(TENANT_ID, 30.0, db=None)
|
||||
r.set.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_handles_redis_error(self):
|
||||
with patch("app.core.auth.get_redis", side_effect=Exception("Redis down")):
|
||||
with patch("app.config.get_settings") as mock_settings:
|
||||
mock_settings.return_value.llm_monthly_budget_usd = 100.0
|
||||
await _check_cost_alerts(TENANT_ID, 50.0, db=None)
|
||||
|
||||
|
||||
class TestMonthReset:
|
||||
"""Tests for monthly cost reset via key expiration."""
|
||||
|
||||
def test_cost_key_changes_per_month(self):
|
||||
key_jan = f"cost:tenant:{TENANT_ID}:month:2026-01"
|
||||
key_feb = f"cost:tenant:{TENANT_ID}:month:2026-02"
|
||||
assert key_jan != key_feb
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_cost_sets_ttl(self):
|
||||
with patch("app.core.auth.get_redis") as mock_get_redis:
|
||||
r = MagicMock()
|
||||
r.incrbyfloat = AsyncMock(return_value=10.0)
|
||||
r.expire = AsyncMock()
|
||||
mock_get_redis.return_value = r
|
||||
with patch("app.ai.llm_client._check_cost_alerts", new_callable=AsyncMock):
|
||||
await _track_tenant_cost(TENANT_ID, 10.0)
|
||||
r.expire.assert_called_once()
|
||||
call_args = r.expire.call_args
|
||||
ttl = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("time")
|
||||
assert ttl == 35 * 24 * 3600
|
||||
@@ -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
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for graceful shutdown & connection draining (B.15).
|
||||
|
||||
Covers:
|
||||
- SIGTERM → shutdown event set
|
||||
- WS drain → all connections closed
|
||||
- Worker shutdown → on_shutdown called
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from app.core.ws_helpers import (
|
||||
drain_all_connections,
|
||||
register_ws_registry,
|
||||
_global_ws_registries,
|
||||
)
|
||||
|
||||
|
||||
class TestShutdownEvent:
|
||||
"""Tests for the API graceful shutdown event."""
|
||||
|
||||
def test_shutdown_event_is_settable(self):
|
||||
"""The _shutdown_event should be settable."""
|
||||
from app.main import _shutdown_event
|
||||
# Reset to clean state
|
||||
_shutdown_event.clear()
|
||||
assert not _shutdown_event.is_set()
|
||||
_shutdown_event.set()
|
||||
assert _shutdown_event.is_set()
|
||||
# Clean up
|
||||
_shutdown_event.clear()
|
||||
|
||||
def test_shutdown_event_is_asyncio_event(self):
|
||||
"""_shutdown_event should be an asyncio.Event."""
|
||||
from app.main import _shutdown_event
|
||||
assert isinstance(_shutdown_event, asyncio.Event)
|
||||
|
||||
|
||||
class TestWebSocketDrain:
|
||||
"""Tests for WebSocket connection draining."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_all_connections_closes_websockets(self):
|
||||
"""drain_all_connections should close all registered WS connections."""
|
||||
# Create mock WebSocket objects
|
||||
ws1 = MagicMock(spec=WebSocket)
|
||||
ws1.send_text = AsyncMock()
|
||||
ws1.close = AsyncMock()
|
||||
ws2 = MagicMock(spec=WebSocket)
|
||||
ws2.send_text = AsyncMock()
|
||||
ws2.close = AsyncMock()
|
||||
|
||||
# Register a test registry
|
||||
test_registry: dict[str, list[WebSocket]] = {
|
||||
"user1": [ws1],
|
||||
"user2": [ws2],
|
||||
}
|
||||
register_ws_registry(test_registry)
|
||||
|
||||
# Drain with 0 grace period for fast test
|
||||
await drain_all_connections(grace_period_seconds=0)
|
||||
|
||||
# Both WebSockets should have received reconnect message and been closed
|
||||
ws1.send_text.assert_called_once()
|
||||
ws2.send_text.assert_called_once()
|
||||
ws1.close.assert_called_once()
|
||||
ws2.close.assert_called_once()
|
||||
|
||||
# Verify reconnect message content
|
||||
call_args = ws1.send_text.call_args[0][0]
|
||||
msg = json.loads(call_args)
|
||||
assert msg["type"] == "reconnect"
|
||||
assert msg["reason"] == "server_shutdown"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_all_connections_handles_empty_registry(self):
|
||||
"""drain_all_connections should handle empty registries gracefully."""
|
||||
# Clear any existing registries from previous tests
|
||||
_global_ws_registries.clear()
|
||||
await drain_all_connections(grace_period_seconds=0)
|
||||
# Should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_all_connections_handles_send_failure(self):
|
||||
"""drain_all_connections should not fail if send_text raises."""
|
||||
ws = MagicMock(spec=WebSocket)
|
||||
ws.send_text = AsyncMock(side_effect=Exception("WS closed"))
|
||||
ws.close = AsyncMock(side_effect=Exception("WS closed"))
|
||||
|
||||
test_registry: dict[str, list[WebSocket]] = {"user1": [ws]}
|
||||
register_ws_registry(test_registry)
|
||||
|
||||
# Should not raise despite send failures
|
||||
await drain_all_connections(grace_period_seconds=0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_ws_registry_dedup(self):
|
||||
"""register_ws_registry should not add the same registry twice."""
|
||||
_global_ws_registries.clear()
|
||||
reg: dict[str, list[WebSocket]] = {}
|
||||
register_ws_registry(reg)
|
||||
register_ws_registry(reg)
|
||||
assert len(_global_ws_registries) == 1
|
||||
|
||||
|
||||
class TestWorkerShutdown:
|
||||
"""Tests for ARQ worker graceful shutdown."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_shutdown_called(self):
|
||||
"""on_shutdown should be callable and close Redis."""
|
||||
from app.core.worker import on_shutdown
|
||||
|
||||
with patch("app.core.auth.close_redis", new_callable=AsyncMock) as mock_close:
|
||||
with patch("app.core.db.get_worker_session_factory") as mock_factory:
|
||||
# Mock the session factory to avoid DB access
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_factory.return_value = mock_ctx
|
||||
|
||||
await on_shutdown({})
|
||||
mock_close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_shutdown_handles_db_error(self):
|
||||
"""on_shutdown should handle DB errors gracefully and still close Redis."""
|
||||
from app.core.worker import on_shutdown
|
||||
|
||||
with patch("app.core.auth.close_redis", new_callable=AsyncMock) as mock_close:
|
||||
with patch("app.core.db.get_worker_session_factory", side_effect=Exception("DB unavailable")):
|
||||
await on_shutdown({})
|
||||
mock_close.assert_called_once()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests for observability & trace_id correlation (B.14).
|
||||
|
||||
Covers:
|
||||
- trace_id is generated per request and present in response header
|
||||
- trace_id is set in structlog contextvars
|
||||
- Sensitive fields are not logged (sanitize_dict)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import structlog
|
||||
|
||||
from app.core.monitoring import sanitize_dict, _sanitize_sensitive_fields, _SENSITIVE_FIELDS
|
||||
|
||||
|
||||
class TestTraceIdPropagation:
|
||||
"""Tests for trace_id generation and propagation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_id_in_response_header(self, client):
|
||||
"""Every response should have X-Trace-Id header."""
|
||||
response = await client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
assert "x-trace-id" in response.headers
|
||||
trace_id = response.headers["x-trace-id"]
|
||||
assert len(trace_id) == 8
|
||||
# Should be hex characters
|
||||
int(trace_id, 16) # Raises ValueError if not hex
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_id_unique_per_request(self, client):
|
||||
"""Each request should get a unique trace_id."""
|
||||
r1 = await client.get("/api/v1/health")
|
||||
r2 = await client.get("/api/v1/health")
|
||||
t1 = r1.headers.get("x-trace-id")
|
||||
t2 = r2.headers.get("x-trace-id")
|
||||
assert t1 is not None
|
||||
assert t2 is not None
|
||||
assert t1 != t2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_id_in_structlog_contextvars(self, client):
|
||||
"""trace_id should be set in structlog contextvars during request processing."""
|
||||
# The middleware sets trace_id in contextvars during request handling.
|
||||
# We verify this by checking that the response has the header (which
|
||||
# is set from the same trace_id variable that was bound to contextvars).
|
||||
response = await client.get("/api/v1/health")
|
||||
assert "x-trace-id" in response.headers
|
||||
# After request completes, contextvars should be cleared
|
||||
ctx = structlog.contextvars.get_contextvars()
|
||||
assert "trace_id" not in ctx or ctx.get("trace_id") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_id_consistent_header_and_body(self, client):
|
||||
"""trace_id in error response body should match X-Trace-Id header."""
|
||||
response = await client.get("/api/v1/contacts/00000000-0000-0000-0000-000000000000")
|
||||
if response.status_code >= 400:
|
||||
body = response.json()
|
||||
header_trace = response.headers.get("x-trace-id")
|
||||
body_trace = body.get("trace_id")
|
||||
# Both should be present and match
|
||||
if header_trace and body_trace:
|
||||
assert header_trace == body_trace
|
||||
|
||||
|
||||
class TestSensitiveFieldSanitization:
|
||||
"""Tests for sensitive field redaction in logs."""
|
||||
|
||||
def test_sanitize_dict_redacts_password(self):
|
||||
data = {"username": "admin", "password": "secret123"}
|
||||
result = sanitize_dict(data)
|
||||
assert result["username"] == "admin"
|
||||
assert result["password"] == "***REDACTED***"
|
||||
|
||||
def test_sanitize_dict_redacts_api_key(self):
|
||||
data = {"api_key": "sk-abc123", "model": "gpt-4"}
|
||||
result = sanitize_dict(data)
|
||||
assert result["api_key"] == "***REDACTED***"
|
||||
assert result["model"] == "gpt-4"
|
||||
|
||||
def test_sanitize_dict_redacts_token(self):
|
||||
data = {"token": "bearer xyz", "user_id": "123"}
|
||||
result = sanitize_dict(data)
|
||||
assert result["token"] == "***REDACTED***"
|
||||
assert result["user_id"] == "123"
|
||||
|
||||
def test_sanitize_dict_redacts_nested(self):
|
||||
data = {"config": {"password": "secret", "host": "localhost"}}
|
||||
result = sanitize_dict(data)
|
||||
assert result["config"]["password"] == "***REDACTED***"
|
||||
assert result["config"]["host"] == "localhost"
|
||||
|
||||
def test_sanitize_dict_extra_sensitive(self):
|
||||
data = {"custom_secret": "value", "name": "test"}
|
||||
result = sanitize_dict(data, extra_sensitive={"custom_secret"})
|
||||
assert result["custom_secret"] == "***REDACTED***"
|
||||
assert result["name"] == "test"
|
||||
|
||||
def test_sanitize_dict_case_insensitive(self):
|
||||
data = {"Password": "secret", "API_KEY": "key123"}
|
||||
result = sanitize_dict(data)
|
||||
assert result["Password"] == "***REDACTED***"
|
||||
assert result["API_KEY"] == "***REDACTED***"
|
||||
|
||||
def test_sanitize_processor_redacts(self):
|
||||
"""The structlog processor should redact sensitive fields."""
|
||||
event_dict = {"password": "secret", "event": "login", "user": "admin"}
|
||||
result = _sanitize_sensitive_fields(None, "info", event_dict)
|
||||
assert result["password"] == "***REDACTED***"
|
||||
assert result["event"] == "login"
|
||||
assert result["user"] == "admin"
|
||||
|
||||
def test_sensitive_fields_include_common_names(self):
|
||||
"""Common sensitive field names should be in the redaction set."""
|
||||
expected = {"password", "api_key", "token", "secret", "authorization"}
|
||||
assert expected.issubset(_SENSITIVE_FIELDS)
|
||||
|
||||
def test_sanitize_dict_preserves_non_sensitive(self):
|
||||
data = {"method": "GET", "path": "/api/v1/health", "status": 200}
|
||||
result = sanitize_dict(data)
|
||||
assert result == data
|
||||
Reference in New Issue
Block a user