File size: 3,690 Bytes
fc2fe9f
167c070
 
 
 
 
 
 
 
 
 
 
 
 
 
fc2fe9f
 
 
 
 
 
 
 
 
 
 
 
 
167c070
fc2fe9f
167c070
 
 
 
 
 
 
fc2fe9f
 
167c070
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
"""HTTP middleware: request ID, structured logging, security, shutdown gate, metrics."""
import logging
import time
import uuid

from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

from ..observability import get_metrics
from ..shutdown import get_shutdown_manager

logger = logging.getLogger("rga_auditor.middleware")


class RequestLoggingMiddleware(BaseHTTPMiddleware):
    """Log every request with structured fields: method, path, status, duration, request_id."""

    async def dispatch(self, request: Request, call_next):
        t0 = time.time()
        response = await call_next(request)
        dt = time.time() - t0
        rid = getattr(request.state, "request_id", "?")
        logger.info("method=%s path=%s status=%d duration=%.3fs request_id=%s",
                     request.method, request.url.path, response.status_code, dt, rid)
        return response


class RequestContextMiddleware(BaseHTTPMiddleware):
    """Attach a request ID and start time to every request."""

    async def dispatch(self, request: Request, call_next):
        rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:16]
        request.state.request_id = rid
        t0 = time.time()
        response = await call_next(request)
        response.headers["X-Request-ID"] = rid
        duration_ms = int((time.time() - t0) * 1000)
        response.headers["X-Response-Time-Ms"] = str(duration_ms)
        return response


class ShutdownGateMiddleware(BaseHTTPMiddleware):
    """During shutdown: 503 on new requests, exempt /health, /readyz, /metrics."""

    EXEMPT = {"/health", "/readyz", "/metrics"}

    async def dispatch(self, request: Request, call_next):
        mgr = get_shutdown_manager()
        if mgr.is_shutting_down and request.url.path not in self.EXEMPT:
            return JSONResponse({"detail": "server shutting down"}, status_code=503)
        return await call_next(request)


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)
        response.headers.setdefault("X-Content-Type-Options", "nosniff")
        response.headers.setdefault("X-Frame-Options", "DENY")
        response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
        return response


class MetricsMiddleware(BaseHTTPMiddleware):
    """Count requests and latency. Use route template (not full path) for cardinality control."""

    async def dispatch(self, request: Request, call_next):
        # resolve route template after dispatch; before we don't know it
        mgr = get_shutdown_manager()
        if mgr.is_shutting_down and request.url.path not in ShutdownGateMiddleware.EXEMPT:
            # Don't count pre-shutdown-rejected requests
            return await call_next(request)
        try:
            await mgr.begin_request()
        except RuntimeError:
            return JSONResponse({"detail": "server shutting down"}, status_code=503)
        try:
            t0 = time.time()
            response = await call_next(request)
            dt = time.time() - t0
            endpoint = _route_template(request)
            get_metrics().observe_request(request.method, endpoint, response.status_code, dt)
            return response
        finally:
            await mgr.end_request()


def _route_template(request: Request) -> str:
    """Return the route template (e.g. /docs/{doc_id}) to keep cardinality low."""
    route = request.scope.get("route")
    if route is not None and getattr(route, "path", None):
        return route.path
    return request.url.path