مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
214 lines
8.2 KiB
Python
214 lines
8.2 KiB
Python
"""Translate domain errors into HTTP responses — and record them.
|
|
|
|
The inner layers never import FastAPI; this module is the only place that knows
|
|
about status codes. It is also the single choke point through which every
|
|
request-scoped failure passes, which makes it the right place to persist them.
|
|
|
|
What gets recorded, and why:
|
|
|
|
* **Unhandled exceptions and 5xx ``AppError``s** — always. A provider being down
|
|
or a quota check failing is an operational fault someone must see.
|
|
* **A bare ``ValueError`` escaping to the handler** — always, at ``warning``.
|
|
The codebase has a typed ``ValidationError`` for expected bad input and uses
|
|
it 30 times, so a bare ``ValueError`` reaching here is usually a typing gap
|
|
worth reviewing. The set of call sites is small and bounded.
|
|
* **Other 4xx** — only when ``ERROR_LOG_CLIENT_ERRORS`` is on. Most 4xx are a
|
|
caller sending bad input, and a public API receives a steady stream of 404s
|
|
from scanners; recording all of them by default would bury the real bugs.
|
|
Recorded at ``warning`` so they never look like faults.
|
|
|
|
Every error body carries the request id, and every response carries it in the
|
|
``X-Request-ID`` header, so a user can quote one string and land on exactly one
|
|
row in the console.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.core.config import settings
|
|
from app.core.errors import AppError
|
|
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
|
from app.infrastructure.error_log import record_error
|
|
from app.interfaces.http.request_context import REQUEST_ID_HEADER, context_of
|
|
|
|
logger = logging.getLogger("agy.api")
|
|
|
|
#: Statuses at or above this are "the server is broken".
|
|
SERVER_ERROR_THRESHOLD = 500
|
|
|
|
#: Cap on the per-field validation detail echoed back and stored. A caller
|
|
#: sending a 10 000-element array should not produce a 10 000-line error row.
|
|
MAX_VALIDATION_ERRORS = 20
|
|
|
|
|
|
def _request_id(request: Request) -> str:
|
|
context = context_of(request)
|
|
return context.request_id if context else ""
|
|
|
|
|
|
def _error_response(
|
|
*,
|
|
status_code: int,
|
|
code: str,
|
|
message: str,
|
|
request_id: str,
|
|
details: object = None,
|
|
) -> JSONResponse:
|
|
"""The one place an error response is built.
|
|
|
|
The request id goes in the header as well as the body, and it has to be set
|
|
*here* rather than by the middleware: an unhandled exception is turned into a
|
|
response by Starlette's ``ServerErrorMiddleware``, which sits outside every
|
|
user middleware, so the middleware never sees the response to add a header
|
|
to. The body alone would leave an HTTP client that only reads headers with
|
|
no way to correlate the failure.
|
|
"""
|
|
error: dict = {"code": code, "message": message, "request_id": request_id}
|
|
if details is not None:
|
|
error["details"] = details
|
|
headers = {REQUEST_ID_HEADER: request_id} if request_id else None
|
|
return JSONResponse(
|
|
status_code=status_code, content={"error": error}, headers=headers
|
|
)
|
|
|
|
|
|
async def _record(
|
|
request: Request,
|
|
*,
|
|
kind: str,
|
|
message: str,
|
|
status_code: int | None,
|
|
exc: BaseException | None = None,
|
|
severity: str = ErrorSeverity.ERROR,
|
|
metadata: dict | None = None,
|
|
) -> None:
|
|
"""Persist a request-scoped failure. Never raises."""
|
|
context = context_of(request)
|
|
try:
|
|
await record_error(
|
|
kind=kind,
|
|
message=message,
|
|
exc=exc,
|
|
source=ErrorSource.HTTP,
|
|
severity=severity,
|
|
status_code=status_code,
|
|
method=context.method if context else request.method,
|
|
path=context.path if context else request.url.path,
|
|
organization_id=context.organization_id if context else None,
|
|
user_id=context.user_id if context else None,
|
|
run_id=context.run_id if context else None,
|
|
request_id=context.request_id if context else "",
|
|
ip=context.ip if context else "",
|
|
user_agent=context.user_agent if context else "",
|
|
metadata=metadata,
|
|
)
|
|
except Exception: # pragma: no cover - the recorder is already total
|
|
logger.exception("error-log: unexpected failure while recording an error")
|
|
|
|
|
|
def register_exception_handlers(app: FastAPI) -> None:
|
|
@app.exception_handler(AppError)
|
|
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
|
is_server_fault = exc.status_code >= SERVER_ERROR_THRESHOLD
|
|
if settings.error_log_enabled and (is_server_fault or settings.error_log_client_errors):
|
|
await _record(
|
|
request,
|
|
kind=exc.code,
|
|
message=exc.message,
|
|
status_code=exc.status_code,
|
|
# Pass the exception so the stack is captured: a
|
|
# ProviderUnavailable raised three layers down is far easier to
|
|
# fix with the traceback than with the message alone.
|
|
exc=exc,
|
|
severity=ErrorSeverity.ERROR if is_server_fault else ErrorSeverity.WARNING,
|
|
metadata={"details": exc.details} if exc.details is not None else None,
|
|
)
|
|
|
|
return _error_response(
|
|
status_code=exc.status_code,
|
|
code=exc.code,
|
|
message=exc.message,
|
|
request_id=_request_id(request),
|
|
details=exc.details,
|
|
)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def _validation_error_handler(
|
|
request: Request, exc: RequestValidationError
|
|
) -> JSONResponse:
|
|
"""Reject malformed input in the standard envelope.
|
|
|
|
FastAPI's default body is ``{"detail": [...]}``, which the web client
|
|
cannot read — it would surface every validation failure as a generic
|
|
"server error (422)". Same status, useful shape.
|
|
"""
|
|
errors = [
|
|
{
|
|
"loc": [str(part) for part in err.get("loc", ())],
|
|
"msg": str(err.get("msg", "")),
|
|
}
|
|
for err in exc.errors()[:MAX_VALIDATION_ERRORS]
|
|
]
|
|
if settings.error_log_enabled and settings.error_log_client_errors:
|
|
await _record(
|
|
request,
|
|
kind="request_validation_error",
|
|
message="; ".join(f"{'.'.join(e['loc'])}: {e['msg']}" for e in errors),
|
|
status_code=422,
|
|
severity=ErrorSeverity.WARNING,
|
|
)
|
|
return _error_response(
|
|
status_code=422,
|
|
code="validation_error",
|
|
message="دادههای ارسالی نامعتبر است.",
|
|
request_id=_request_id(request),
|
|
details=errors,
|
|
)
|
|
|
|
@app.exception_handler(ValueError)
|
|
async def _value_error_handler(request: Request, exc: ValueError) -> JSONResponse:
|
|
if settings.error_log_enabled:
|
|
await _record(
|
|
request,
|
|
kind="value_error",
|
|
message=str(exc),
|
|
status_code=422,
|
|
exc=exc,
|
|
severity=ErrorSeverity.WARNING,
|
|
)
|
|
return _error_response(
|
|
status_code=422,
|
|
code="validation_error",
|
|
message=str(exc),
|
|
request_id=_request_id(request),
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def _unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
"""The last line of defence: an unexpected failure.
|
|
|
|
Recorded unconditionally when logging is enabled — this is precisely the
|
|
class of error the console exists for.
|
|
"""
|
|
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
|
if settings.error_log_enabled:
|
|
await _record(
|
|
request,
|
|
kind="unhandled_exception",
|
|
message=str(exc) or type(exc).__name__,
|
|
status_code=500,
|
|
exc=exc,
|
|
severity=ErrorSeverity.CRITICAL,
|
|
)
|
|
return _error_response(
|
|
status_code=500,
|
|
code="internal_error",
|
|
message="خطای داخلی سرور. لطفاً دوباره تلاش کنید.",
|
|
request_id=_request_id(request),
|
|
)
|