"""Доменные ошибки (spec directories-api): обработчик отдаёт `{"detail": {"code", "message", **extra}}`. `code` — машинный код ошибки (`NOT_FOUND`, `INN_EXISTS`, …), `message` — текст по-русски, `extra` — дополнительные поля ответа (например, `legal_entity_id` существующей записи). Ответы `HTTPException` (401/403 строкой) — отдельно. """ import uuid from typing import Any from fastapi import HTTPException from fastapi import status as http_status _RESERVED_KEYS = frozenset({"code", "message"}) class DomainError(Exception): """Базовая доменная ошибка с HTTP-статусом ответа.""" def __init__(self, status: int, code: str, message: str, extra: dict[str, Any] | None = None) -> None: extra = dict(extra or {}) shadowed = sorted(_RESERVED_KEYS & extra.keys()) if shadowed: raise ValueError(f"дополнительные поля ошибки не могут называться {', '.join(shadowed)}") super().__init__(message) self.status = status self.code = code self.message = message self.extra = extra class NotFoundError(DomainError): """404: объекта нет или он вне видимости пользователя (тело одинаковое — без утечки существования).""" def __init__(self, message: str = "объект не найден") -> None: super().__init__(http_status.HTTP_404_NOT_FOUND, "NOT_FOUND", message) class ConflictError(DomainError): """409: конфликт с существующими данными (`INN_EXISTS`, `MEMBER_EXISTS`, …).""" def __init__(self, code: str, message: str, **extra: Any) -> None: super().__init__(http_status.HTTP_409_CONFLICT, code, message, extra) class DomainValidationError(DomainError): """422: нарушение доменного правила валидации (`INVALID_INN`, `DIFFERENT_COMPANY_GROUP`, …).""" def __init__(self, code: str, message: str, **extra: Any) -> None: super().__init__(http_status.HTTP_422_UNPROCESSABLE_CONTENT, code, message, extra) class PermissionDeniedError(HTTPException): """403 АВТОРИЗАЦИИ: у пользователя нет права `code` (`require` маршрута или право на часть операции, например удаление алиаса формой ЮЛ, #84). Ответ строкой, как у любого 403 «недостаточно прав».""" def __init__(self, code: str) -> None: super().__init__(http_status.HTTP_403_FORBIDDEN, detail=f"недостаточно прав: требуется {code}") self.permission = code class AuthenticationFailedError(HTTPException): """401/403 АУТЕНТИФИКАЦИИ (токен не принят; пользователь не найден, отключён, неоднозначен) — не авторизации. Ответ — как у `HTTPException` (`{"detail": "<строка>"}`); обработчик дополнительно пишет LOGIN_FAILED в отдельной транзакции (`services/session_audit.py`, OQ-64). `audit_description` — текст для журнала (без токена), `user_id` — найденный пользователь (например, отключённый) или `None`. """ def __init__( self, status_code: int, detail: str, *, audit_description: str, user_id: uuid.UUID | None = None, headers: dict[str, str] | None = None, ) -> None: super().__init__(status_code, detail=detail, headers=headers) self.audit_description = audit_description self.user_id = user_id