مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
"""Per-request correlation and actor context.
|
|
|
|
Every request gets an id before it reaches a route. That id is returned in the
|
|
``X-Request-ID`` response header, included in every error body, and written onto
|
|
every error record — so a user reporting "it broke" can hand over one string
|
|
that points at exactly one row in the error console.
|
|
|
|
The same object carries the resolved tenant and user. Authentication happens in
|
|
dependencies, long after the middleware has run, so the middleware seeds the
|
|
context with what it knows (method, path, ip, user agent) and the auth
|
|
dependencies fill in the rest via :func:`note_actor`. Error handlers then read
|
|
one object instead of re-deriving anything.
|
|
|
|
Deliberately stored on ``request.state`` rather than in a ``contextvar``: it is
|
|
the same object the handler already has in hand, it survives every task
|
|
boundary Starlette may introduce, and it cannot leak between requests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
|
|
from fastapi import Request
|
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
from starlette.responses import Response
|
|
|
|
#: Header used to both accept a caller-supplied id and echo it back. Accepting
|
|
#: an upstream id matters when the API sits behind a reverse proxy or a
|
|
#: frontend BFF that already correlates its own logs.
|
|
REQUEST_ID_HEADER = "X-Request-ID"
|
|
|
|
#: Long enough to be unique, short enough to paste into a bug report.
|
|
REQUEST_ID_LENGTH = 16
|
|
|
|
_STATE_ATTR = "error_context"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RequestContext:
|
|
"""What we know about the request an error happened on."""
|
|
|
|
request_id: str
|
|
method: str
|
|
path: str
|
|
ip: str = ""
|
|
user_agent: str = ""
|
|
#: Filled in by the auth dependencies once the caller is authenticated.
|
|
organization_id: str | None = None
|
|
user_id: str | None = None
|
|
#: Set when a failure happens inside a specific agent run.
|
|
run_id: str | None = None
|
|
#: Query string, kept separate from ``path`` so fingerprints group across
|
|
#: different filters on the same endpoint.
|
|
query: str = ""
|
|
|
|
|
|
def new_request_id() -> str:
|
|
return uuid.uuid4().hex[:REQUEST_ID_LENGTH]
|
|
|
|
|
|
def client_ip(request: Request) -> str:
|
|
"""Real client address, honouring one proxy hop.
|
|
|
|
Same rule as ``deps.client_meta``: a trusted reverse proxy sets
|
|
``X-Forwarded-For``, and the left-most entry is the original caller.
|
|
"""
|
|
forwarded = request.headers.get("x-forwarded-for", "")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip()
|
|
return request.client.host if request.client else ""
|
|
|
|
|
|
def context_of(request: Request) -> RequestContext | None:
|
|
"""The context for this request, or ``None`` outside the middleware."""
|
|
return getattr(request.state, _STATE_ATTR, None)
|
|
|
|
|
|
def note_actor(
|
|
request: Request,
|
|
*,
|
|
user_id: str | None = None,
|
|
organization_id: str | None = None,
|
|
run_id: str | None = None,
|
|
) -> None:
|
|
"""Record who/which tenant this request belongs to.
|
|
|
|
Called from the auth dependencies. Silent when there is no context (a
|
|
direct unit-test call to a dependency, for instance) — correlation is
|
|
telemetry, and telemetry must never be the reason a request fails.
|
|
"""
|
|
context = context_of(request)
|
|
if context is None:
|
|
return
|
|
if user_id:
|
|
context.user_id = user_id
|
|
if organization_id:
|
|
context.organization_id = organization_id
|
|
if run_id:
|
|
context.run_id = run_id
|
|
|
|
|
|
class RequestContextMiddleware(BaseHTTPMiddleware):
|
|
"""Attach a :class:`RequestContext` to every request and echo its id."""
|
|
|
|
async def dispatch(
|
|
self, request: Request, call_next: RequestResponseEndpoint
|
|
) -> Response:
|
|
incoming = request.headers.get(REQUEST_ID_HEADER, "").strip()
|
|
# Cap the accepted value: an unbounded header would let a caller write
|
|
# arbitrarily long ids into the error table.
|
|
request_id = incoming[:64] if incoming else new_request_id()
|
|
|
|
setattr( # noqa: B010 - the attribute name is a module constant
|
|
request.state,
|
|
_STATE_ATTR,
|
|
RequestContext(
|
|
request_id=request_id,
|
|
method=request.method,
|
|
path=request.url.path,
|
|
query=request.url.query or "",
|
|
ip=client_ip(request),
|
|
user_agent=request.headers.get("user-agent", "")[:255],
|
|
),
|
|
)
|
|
response = await call_next(request)
|
|
response.headers[REQUEST_ID_HEADER] = request_id
|
|
return response
|
|
|
|
|
|
__all__ = [
|
|
"REQUEST_ID_HEADER",
|
|
"RequestContext",
|
|
"RequestContextMiddleware",
|
|
"client_ip",
|
|
"context_of",
|
|
"new_request_id",
|
|
"note_actor",
|
|
]
|