/
systemsstrategyy
/
Treker
Обзор
Документация
Войти
/
systemsstrategyy
/
Treker
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
api/core/errors.py
243 строки
11 KB
SystemsStrategy
Синхронизация с актуальной линией разработки (август 2026)
06 авг 2026, 12:52
06 авг 2026, 12:52
19af6aa
Код
Авторство
О чём код?
"""Доменные исключения и RFC 9457 Problem Details handler. Иерархия используется во всех модулях. FastAPI-обработчик ловит любой DomainException и превращает в application/problem+json с полями type/title/status/detail плюс расширение `code` (snake_case stable identifier, который парсит фронтенд). """ from __future__ import annotations from typing import Any, ClassVar import structlog from fastapi import Request from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse _log = structlog.get_logger(__name__) class DomainException(Exception): # noqa: N818 — имя зафиксировано в ADR-0001 §7 """Базовый класс для всех доменных исключений. Подклассы переопределяют class-level `code`/`status`/`title`. `detail` — сообщение для пользователя (русский), передаётся в конструктор. """ code: ClassVar[str] = "domain_error" status: ClassVar[int] = 400 title: ClassVar[str] = "Domain error" def __init__(self, detail: str = "") -> None: super().__init__(detail or self.title) self.detail = detail or "" class ValidationError(DomainException): code = "validation_failed" status = 422 title = "Validation failed" class ForeignEmailNotAllowed(DomainException): """422 — регистрация/приглашение с иностранной почтой запрещены (149-ФЗ). Российский ресурс обязан авторизовать пользователей РФ только разрешёнными способами; иностранный email как идентификатор не принимается. Применяется на регистрации и создании приглашения, не ретроактивно (вход существующих пользователей не блокируется).""" code = "foreign_email_not_allowed" status = 422 title = "Foreign email not allowed" class NotFoundError(DomainException): code = "not_found" status = 404 title = "Not found" class NotAuthenticated(DomainException): """401 Unauthorized — креды отсутствуют или невалидны. Семантика по HTTP-стандарту разнится с `PermissionDenied`: - **401** = клиент не аутентифицирован (нет валидного токена, протух, подпись неверна, юзер удалён, аккаунт деактивирован) → клиент должен ре-логиниться. - **403** = клиент аутентифицирован, но прав на действие не хватает (RBAC, чужой ресурс) → ре-логин не поможет. Любая ошибка в auth-pipeline (decode JWT, FileNotFoundError на ключе, ValueError на claims, Redis-fail на denylist-check) конвертится в `NotAuthenticated` чтобы клиент получил чистый 401 а не 500.""" code = "not_authenticated" status = 401 title = "Not authenticated" class PermissionDenied(DomainException): code = "permission_denied" status = 403 title = "Permission denied" class EmailNotVerified(DomainException): code = "email_not_verified" status = 403 title = "Email not verified" class CannotModifySelf(DomainException): """400 — admin пытается изменить собственный аккаунт (роль/активность). Self-lockout guard: иначе admin мог бы случайно лишить себя прав или деактивироваться, оставив организацию без управляющего.""" code = "cannot_modify_self" status = 400 title = "Cannot modify own account" class QuotaExceeded(DomainException): code = "quota_exceeded" status = 429 title = "Quota exceeded" class BusinessRuleViolation(DomainException): code = "business_rule_violated" status = 409 title = "Business rule violated" async def domain_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Конвертирует DomainException в JSON по RFC 9457. Сигнатура (Request, Exception) — требование FastAPI; внутри проверяем тип и формируем Problem Details ответ. """ if not isinstance(exc, DomainException): # Не наш тип — отдаём общий 500 (FastAPI всё равно перехватит выше). return JSONResponse( status_code=500, content={ "type": "urn:problem:tracker:internal_error", "title": "Internal Server Error", "status": 500, "detail": "", "code": "internal_error", }, media_type="application/problem+json", ) payload: dict[str, Any] = { "type": f"urn:problem:tracker:{exc.code}", "title": exc.title, "status": exc.status, "detail": exc.detail, "code": exc.code, } # Unfold detail_payload как top-level fields для structured error info. # Subclass'ы (например `billing.LimitReached`) могут поставить # `self.detail_payload = {"resource": ..., "current": ..., "max": ...}`, # тогда эти ключи попадают в body на одном уровне с type/title/detail. # RFC 9457 §3.1 явно разрешает extension members; frontend читает их # для построения actionable UI ("Лимит spaces 1/1, обнови до Pro"). # См. issue #125. detail_payload = getattr(exc, "detail_payload", None) if isinstance(detail_payload, dict): # Не overwrite базовые RFC 9457 поля если subclass случайно их # положил в payload — это защита invariant'а формата. reserved = {"type", "title", "status", "detail", "code"} for key, value in detail_payload.items(): if key not in reserved: payload[key] = value return JSONResponse( status_code=exc.status, content=payload, media_type="application/problem+json", ) async def validation_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Конвертирует Pydantic RequestValidationError в RFC 9457 формат. Дефолтный FastAPI handler возвращает body вида `{"detail": [{loc, msg, type, ...}]}` с media-type `application/json` — это нарушение RFC 9457 (issue #140). Мы переопределяем поведение глобально через `app.add_exception_handler` — закрывает Pydantic-422 на всех endpoints за один patch. `errors` остаётся в payload (RFC 9457 §3.1 разрешает extension members) — фронту нужен полный list для подсветки конкретных полей в форме. `detail` — человекочитаемая агрегация первой ошибки для toast'а / log'а. Сигнатура `(Request, Exception)` — требование FastAPI; внутри cast'имся в `RequestValidationError`. """ if not isinstance(exc, RequestValidationError): # Fallback на случай если handler зарегистрирован неверно. # FastAPI всё равно перехватит выше → 500. raise exc # `ctx` ошибки может содержать оригинальный объект исключения (ValueError, # выброшенный кастомным field_validator, например ConsentMixin) — он не # JSON-сериализуем и валит JSONResponse в 500. Прогоняем через # jsonable_encoder, приводя любые Exception к строке: payload остаётся # сериализуемым, сообщение валидатора сохраняется (152-ФЗ-согласие → 422). errors = jsonable_encoder(exc.errors(), custom_encoder={Exception: str}) first = errors[0] if errors else {} # loc — последовательность из ('body'|'query'|..., 'field_name', ...). # Конструируем dotted path начиная со второго элемента (skip кат). loc_parts = [str(p) for p in first.get("loc", [])[1:]] loc_path = ".".join(loc_parts) if loc_parts else "" msg = str(first.get("msg", "Validation error")) detail = f"{loc_path}: {msg}" if loc_path else msg payload: dict[str, Any] = { "type": "urn:problem:tracker:validation_failed", "title": "Validation Failed", "status": 422, "detail": detail, "code": "validation_failed", "errors": errors, } return JSONResponse( status_code=422, content=payload, media_type="application/problem+json", ) async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Catch-all для unhandled exceptions → RFC 9457 500. Дефолтный FastAPI handler возвращает text/plain «Internal Server Error» — нарушает RFC 9457 + frontend unified error-shape. Этот handler: 1. Логирует exception с full traceback через structlog (включая request_id из context-var middleware'а). 2. Возвращает безопасный body без stacktrace leakage (атакующий не должен видеть internals). Регистрируется ПОСЛЕДНИМ в main.py — `DomainException` и `RequestValidationError` handler'ы перехватывают свои типы первыми. """ _log.exception( "api.unhandled_exception", path=request.url.path, method=request.method, exc_type=type(exc).__name__, ) return JSONResponse( status_code=500, content={ "type": "urn:problem:tracker:internal_error", "title": "Internal Server Error", "status": 500, "detail": "", "code": "internal_error", }, media_type="application/problem+json", )