arm-core/api/src/core/exception_handlers.py
Andrey Epifancev ef7458eddb feat: аудит входа LOGIN / LOGIN_FAILED (OQ-64, #9 п.5)
Раздел SESSION, object_type app_user, IP и user-agent запроса
(`services/session_audit.py`):
- LOGIN — выдача dev-токена (в транзакции запроса) и первый успешный
  `current_user` пользователя после старта процесса (`SessionAudit.logged_in`,
  в памяти процесса; параллельные первые запросы — одно событие);
- LOGIN_FAILED — 401 (токен не принят: user_id пуст, причина без токена) и 403
  аутентификации (не зарегистрирован / отключён / неоднозначен: email в
  description, user_id — если найден). `AuthenticationFailedError` (подкласс
  HTTPException) обрабатывается после отката транзакции запроса, запись — в
  отдельной транзакции `session_transaction`, поэтому остаётся в журнале.
Без заголовка Authorization и 403 «недостаточно прав» — не события входа;
ошибка записи аудита входа ответ не меняет.

Тесты: `test_login_audit.py` (в т.ч. фиксация при откате запроса на
`committed_app`); счётчики следов импорта не учитывают события SESSION.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 13:27:38 +04:00

120 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Обработчики исключений приложения: единый формат ответа `{"detail": ...}`.
`IntegrityError` → 409: для известных уникальных ограничений — доменный 409 с кодом из
`common/constraint_conflicts.py` (гонка pre-check/INSERT), иначе общая строка; прочие `SQLAlchemyError` → 500.
Ошибки БД пишутся в лог одной записью уровня ERROR без текста исключения: `str()` ошибки SQLAlchemy/asyncpg содержит
параметры запроса и DETAIL PostgreSQL со значениями ключей (email, ИНН) — это персональные данные. В лог идут путь и
метод запроса, классы ошибок, SQLSTATE, код разрешённого конфликта, текст SQL с плейсхолдерами (без значений, усечён) и
стек вызовов без сообщения исключения.
"""
import traceback
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from loguru import logger
from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError
from src.common.constraint_conflicts import resolve_unique_conflict
from src.common.exceptions import AuthenticationFailedError, DomainError
from src.services.session_audit import SessionAudit, request_client
LOG_STATEMENT_LIMIT = 200 # символов SQL-текста (с плейсхолдерами) в записи лога
def log_db_error(request: Request, exc: SQLAlchemyError, message: str, *, resolved_code: str | None = None) -> None:
"""Одна запись ERROR об ошибке БД при обработке запроса — без параметров запроса и текста ошибки драйвера.
`resolved_code` — код доменного конфликта, в который отображена гонка уникальных ограничений (для диагностики).
"""
orig = exc.orig if isinstance(exc, DBAPIError) else None
statement = exc.statement if isinstance(exc, DBAPIError) else None
logger.error(
message + " path={path}, method={method}, error_type={error_type}, db_error_type={db_error_type}, "
"sqlstate={sqlstate}, resolved_code={resolved_code}, statement={statement}\n{stack}",
path=request.url.path,
method=request.method,
error_type=type(exc).__name__,
db_error_type=type(orig).__name__ if orig is not None else None,
sqlstate=getattr(orig, "sqlstate", None),
resolved_code=resolved_code,
statement=repr(statement[:LOG_STATEMENT_LIMIT]) if statement else None,
stack="".join(traceback.format_tb(exc.__traceback__)).rstrip(),
)
def register_exception_handlers(app: FastAPI) -> None:
# Доменные ошибки — до обработчиков `SQLAlchemyError`/`Exception` (spec directories-api: формат detail-объекта)
@app.exception_handler(DomainError)
async def domain_error_handler(_: Request, exc: DomainError):
return JSONResponse(
status_code=exc.status,
content={"detail": {"code": exc.code, "message": exc.message, **jsonable_encoder(exc.extra)}},
)
@app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(_: Request, exc: RequestValidationError):
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
# ctx ошибки может содержать исключение (ValueError из валидатора) — сериализуем через jsonable_encoder
content={"detail": jsonable_encoder(exc.errors())},
)
@app.exception_handler(IntegrityError)
async def integrity_error_handler(request: Request, exc: IntegrityError):
# Гонка pre-check/INSERT по известному уникальному ограничению → тот же 409 с кодом, что отдал бы pre-check
conflict = resolve_unique_conflict(exc)
log_db_error(
request,
exc,
"Нарушение целостности данных в БД при обработке запроса.",
resolved_code=conflict.code if conflict is not None else None,
)
if conflict is not None:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": {"code": conflict.code, "message": conflict.message}},
)
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": "нарушение целостности данных в БД"},
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError):
log_db_error(request, exc, "Ошибка базы данных при обработке запроса.")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "ошибка базы данных"},
)
@app.exception_handler(AuthenticationFailedError)
async def authentication_failed_handler(request: Request, exc: AuthenticationFailedError):
# Обработчик работает после отката транзакции запроса: LOGIN_FAILED пишется в отдельной (OQ-64). Приложение
# без `state.session_audit` (тестовые мини-приложения) отвечает так же, но без записи в журнал
session_audit = getattr(request.app.state, "session_audit", None)
if isinstance(session_audit, SessionAudit):
ip_address, user_agent = request_client(request)
await session_audit.login_failed(
exc.user_id, exc.audit_description, ip_address=ip_address, user_agent=user_agent
)
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers)
@app.exception_handler(HTTPException)
async def http_exception_handler(_: Request, exc: HTTPException):
# Не ломаем существующие ответы endpoint-ов и прокидываем исходный detail.
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(_: Request, exc: Exception):
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "внутренняя ошибка сервера"},
)