نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"""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),
|
||||
)
|
||||
Reference in New Issue
Block a user