Root cause: Both RequestLoggingMiddleware and ErrorLoggingMiddleware used BaseHTTPMiddleware and re-raised exceptions. When an exception (like a 401 from auth) was re-raised, the response never flowed back through CORSMiddleware, so browsers received error responses without CORS headers. This made 401 errors appear as CORS errors, breaking session resume and other operations after token expiry. Fix: Both middlewares now catch exceptions and return JSONResponse objects (with correct status codes from HTTPException) instead of re-raising. This ensures responses always flow through CORSMiddleware and receive proper Access-Control-Allow-Origin headers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
149 lines
5.0 KiB
Python
149 lines
5.0 KiB
Python
"""
|
|
Middleware for request logging and tracking.
|
|
|
|
Implements correlation ID tracking for requests and comprehensive logging
|
|
following 2026 FastAPI best practices.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from typing import Callable
|
|
|
|
from fastapi import Request, Response
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.types import ASGIApp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
"""
|
|
Middleware to log all HTTP requests with timing and correlation IDs.
|
|
|
|
Features:
|
|
- Generates unique correlation ID for each request
|
|
- Logs request method, path, and client IP
|
|
- Measures and logs request processing time
|
|
- Logs response status code
|
|
- Adds correlation ID to response headers for tracing
|
|
"""
|
|
|
|
async def dispatch(
|
|
self, request: Request, call_next: Callable
|
|
) -> Response:
|
|
# Generate correlation ID for request tracking
|
|
correlation_id = str(uuid.uuid4())
|
|
request.state.correlation_id = correlation_id
|
|
|
|
# Get client IP
|
|
client_host = request.client.host if request.client else "unknown"
|
|
|
|
# Log incoming request
|
|
logger.info(
|
|
f"Request started - "
|
|
f"method={request.method} "
|
|
f"path={request.url.path} "
|
|
f"client={client_host} "
|
|
f"correlation_id={correlation_id}"
|
|
)
|
|
|
|
# Process request and measure time
|
|
start_time = time.time()
|
|
|
|
try:
|
|
response = await call_next(request)
|
|
process_time = time.time() - start_time
|
|
|
|
# Add correlation ID to response headers
|
|
response.headers["X-Correlation-ID"] = correlation_id
|
|
response.headers["X-Process-Time"] = str(process_time)
|
|
|
|
# Log response
|
|
logger.info(
|
|
f"Request completed - "
|
|
f"method={request.method} "
|
|
f"path={request.url.path} "
|
|
f"status={response.status_code} "
|
|
f"duration={process_time:.3f}s "
|
|
f"correlation_id={correlation_id}"
|
|
)
|
|
|
|
return response
|
|
|
|
except Exception as exc:
|
|
process_time = time.time() - start_time
|
|
|
|
# Log error
|
|
logger.error(
|
|
f"Request failed - "
|
|
f"method={request.method} "
|
|
f"path={request.url.path} "
|
|
f"duration={process_time:.3f}s "
|
|
f"correlation_id={correlation_id} "
|
|
f"error={str(exc)}",
|
|
exc_info=True
|
|
)
|
|
|
|
# Return a proper response so it flows through CORSMiddleware.
|
|
# Re-raising from BaseHTTPMiddleware bypasses CORS headers.
|
|
from starlette.responses import JSONResponse
|
|
from fastapi import HTTPException
|
|
if isinstance(exc, HTTPException):
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"detail": exc.detail},
|
|
headers={"X-Correlation-ID": correlation_id, "X-Process-Time": f"{process_time:.3f}"},
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error"},
|
|
headers={"X-Correlation-ID": correlation_id, "X-Process-Time": f"{process_time:.3f}"},
|
|
)
|
|
|
|
|
|
class ErrorLoggingMiddleware(BaseHTTPMiddleware):
|
|
"""
|
|
Middleware to catch and log unhandled exceptions.
|
|
|
|
Ensures all exceptions are logged before being returned to the client,
|
|
providing full stack traces for debugging.
|
|
|
|
IMPORTANT: Returns a JSONResponse instead of re-raising so the response
|
|
flows back through CORSMiddleware and gets proper CORS headers. Re-raising
|
|
exceptions from BaseHTTPMiddleware bypasses CORS, causing browsers to
|
|
report CORS errors instead of the actual error (e.g., 401).
|
|
"""
|
|
|
|
async def dispatch(
|
|
self, request: Request, call_next: Callable
|
|
) -> Response:
|
|
try:
|
|
response = await call_next(request)
|
|
return response
|
|
except Exception as exc:
|
|
correlation_id = getattr(request.state, "correlation_id", "unknown")
|
|
|
|
logger.error(
|
|
f"Unhandled exception - "
|
|
f"method={request.method} "
|
|
f"path={request.url.path} "
|
|
f"correlation_id={correlation_id}",
|
|
exc_info=True
|
|
)
|
|
|
|
# Return a proper response so it flows through CORSMiddleware.
|
|
# If we re-raise, the response never passes through CORS and
|
|
# browsers see a CORS error instead of the actual error.
|
|
from starlette.responses import JSONResponse
|
|
from fastapi import HTTPException
|
|
if isinstance(exc, HTTPException):
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"detail": exc.detail},
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error"},
|
|
)
|