نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
+213
View File
@@ -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),
)