Add Kubernetes liveness/readiness probes and middleware order validation
- Split /health into /health/live (liveness) and /health/ready (readiness) following Kubernetes conventions. Combined /health retained for backward compatibility with existing Docker HEALTHCHECK definitions. - Add ReadyCheck and ReadyResponse models for structured readiness output. - Add _assert_middleware_order() startup check enforcing: RateLimit → Csrf → CorrelationId middleware chain. - Register CorrelationIdMiddleware, CsrfMiddleware, RateLimitMiddleware in create_app() with documented required order (reverse of processing). - Add correlation.py, csrf.py, rate_limit.py middleware modules. - Add health probe tests in test_health_probes.py. - Update test_main.py with middleware order assertion tests. - Update frontend useFetchData hook tests. - Docs: update Deployment.md with Kubernetes probe config examples.
This commit is contained in:
@@ -314,13 +314,13 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
def _get_error_code(exc: Exception) -> str:
|
||||
"""Get the machine-readable error code from an exception.
|
||||
|
||||
|
||||
First checks if the exception has an error_code class attribute.
|
||||
Falls back to converting the exception class name to snake_case.
|
||||
|
||||
|
||||
Args:
|
||||
exc: The exception instance.
|
||||
|
||||
|
||||
Returns:
|
||||
A snake_case error code string.
|
||||
"""
|
||||
@@ -334,12 +334,12 @@ def _get_error_code(exc: Exception) -> str:
|
||||
|
||||
def _get_error_metadata(exc: Exception) -> ErrorMetadata:
|
||||
"""Get structured metadata from an exception.
|
||||
|
||||
|
||||
Calls the exception's get_error_metadata() method if available.
|
||||
|
||||
|
||||
Args:
|
||||
exc: The exception instance.
|
||||
|
||||
|
||||
Returns:
|
||||
A dictionary of metadata safe for API responses.
|
||||
"""
|
||||
@@ -350,12 +350,12 @@ def _get_error_metadata(exc: Exception) -> ErrorMetadata:
|
||||
|
||||
def _get_correlation_id(request: Request) -> str | None:
|
||||
"""Extract correlation ID from request state if available.
|
||||
|
||||
|
||||
The correlation ID is set by CorrelationIdMiddleware.
|
||||
|
||||
|
||||
Args:
|
||||
request: The incoming FastAPI request.
|
||||
|
||||
|
||||
Returns:
|
||||
The correlation ID string, or None if not present.
|
||||
"""
|
||||
@@ -802,7 +802,9 @@ async def _request_validation_error_handler(
|
||||
_EXACT_ALLOWED: frozenset[str] = frozenset(
|
||||
{
|
||||
"/api/v1/setup", # GET/POST /api/v1/setup
|
||||
"/api/v1/health", # Health check endpoint
|
||||
"/api/v1/health", # Health check endpoint (combined)
|
||||
"/api/v1/health/live", # Kubernetes liveness probe
|
||||
"/api/v1/health/ready", # Kubernetes readiness probe
|
||||
"/api/docs", # Swagger UI
|
||||
"/api/redoc", # ReDoc
|
||||
"/api/openapi.json", # OpenAPI schema
|
||||
@@ -988,6 +990,48 @@ def _enforce_single_worker() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _assert_middleware_order(app: FastAPI) -> None:
|
||||
"""Assert required middleware order at startup.
|
||||
|
||||
Raises:
|
||||
AssertionError: If middleware are not in the required order.
|
||||
"""
|
||||
registered = [m.cls.__name__ for m in app.user_middleware]
|
||||
|
||||
# Find positions; skip middleware not in the security-critical chain
|
||||
order: tuple[str, ...] = (
|
||||
"RateLimitMiddleware",
|
||||
"CsrfMiddleware",
|
||||
"CorrelationIdMiddleware",
|
||||
)
|
||||
|
||||
positions = {name: registered.index(name) for name in order if name in registered}
|
||||
|
||||
# RateLimitMiddleware must be before CsrfMiddleware
|
||||
if (
|
||||
"RateLimitMiddleware" in positions
|
||||
and "CsrfMiddleware" in positions
|
||||
and positions["RateLimitMiddleware"] > positions["CsrfMiddleware"]
|
||||
):
|
||||
raise AssertionError(
|
||||
f"Middleware order violation: RateLimitMiddleware (position {positions['RateLimitMiddleware']}) "
|
||||
f"must be registered before CsrfMiddleware (position {positions['CsrfMiddleware']}). "
|
||||
f"Current order: {registered}"
|
||||
)
|
||||
|
||||
# CsrfMiddleware must be before CorrelationIdMiddleware
|
||||
if (
|
||||
"CsrfMiddleware" in positions
|
||||
and "CorrelationIdMiddleware" in positions
|
||||
and positions["CsrfMiddleware"] > positions["CorrelationIdMiddleware"]
|
||||
):
|
||||
raise AssertionError(
|
||||
f"Middleware order violation: CsrfMiddleware (position {positions['CsrfMiddleware']}) "
|
||||
f"must be registered before CorrelationIdMiddleware (position {positions['CorrelationIdMiddleware']}). "
|
||||
f"Current order: {registered}"
|
||||
)
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
"""Create and configure the BanGUI FastAPI application.
|
||||
|
||||
@@ -1066,11 +1110,18 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
)
|
||||
|
||||
# --- Middleware ---
|
||||
# Note: middleware is applied in reverse order of registration.
|
||||
# SecurityHeadersMiddleware must run early but after CORS/CSRF so headers
|
||||
# are added to all responses including error responses.
|
||||
# CorrelationIdMiddleware must run first (added last) so correlation ID
|
||||
# is available to all downstream handlers and loggers.
|
||||
# Note: Starlette applies middleware in reverse order of registration
|
||||
# (last registered = outermost; first to see request, last to see response).
|
||||
#
|
||||
# Required processing order (outermost → innermost):
|
||||
# 1. CorrelationIdMiddleware – generates/extracts correlation ID first
|
||||
# 2. CsrfMiddleware – CSRF validation after correlation ID is available
|
||||
# 3. RateLimitMiddleware – rate limiting last (needs correlation ID for logging)
|
||||
#
|
||||
# This requires registration order (reverse of processing):
|
||||
# 1. RateLimitMiddleware (registered first → innermost for responses)
|
||||
# 2. CsrfMiddleware
|
||||
# 3. CorrelationIdMiddleware (registered last → outermost for requests)
|
||||
app.add_middleware(CorrelationIdMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(SetupRedirectMiddleware)
|
||||
@@ -1083,6 +1134,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
settings=resolved_settings,
|
||||
)
|
||||
|
||||
# Validate middleware order before returning the app.
|
||||
# Raising loud errors at startup is intentional — a misconfigured middleware
|
||||
# stack is a security-critical defect that must not slip through silently.
|
||||
_assert_middleware_order(app)
|
||||
|
||||
|
||||
# --- Exception handlers ---
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user