"""Доменные исключения и единый конверт ошибок API. Формат ответа: {"error": {"code": "...", "message": "...", "details": {...}}} """ from __future__ import annotations from typing import Any from fastapi import Request from fastapi.responses import JSONResponse class AppError(Exception): """Базовая ошибка приложения со стабильным машинным кодом.""" status_code: int = 400 code: str = "BAD_REQUEST" def __init__( self, message: str, *, code: str | None = None, status_code: int | None = None, details: dict[str, Any] | None = None, ) -> None: super().__init__(message) self.message = message if code is not None: self.code = code if status_code is not None: self.status_code = status_code self.details = details def to_response(self) -> JSONResponse: return JSONResponse( status_code=self.status_code, content={ "error": { "code": self.code, "message": self.message, "details": self.details, } }, ) class NotFoundError(AppError): status_code = 404 code = "NOT_FOUND" class ForbiddenError(AppError): status_code = 403 code = "FORBIDDEN" class ValidationError(AppError): status_code = 422 code = "VALIDATION_ERROR" class ConflictError(AppError): status_code = 409 code = "CONFLICT" class AuthError(AppError): status_code = 401 code = "UNAUTHORIZED" # ─── Конкретные ошибки со стабильными кодами для фронта ─────────────────────── class NoGroupError(AppError): status_code = 409 code = "NO_GROUP" def __init__(self) -> None: super().__init__("Вы не состоите ни в одной группе.") class NotGroupMemberError(ForbiddenError): code = "NOT_GROUP_MEMBER" def __init__(self) -> None: super().__init__("Вы не являетесь участником этой группы.") class NicknameTakenError(ConflictError): code = "NICKNAME_TAKEN" def __init__(self) -> None: super().__init__("Никнейм уже занят.") class TelegramNicknameRequiredError(ConflictError): """Первый вход через Telegram, но предлагаемый ник (тег) занят/некорректен — фронт должен показать выбор ника и повторить регистрацию через /telegram/register. `suggested` идёт в details для предзаполнения поля.""" code = "TELEGRAM_NICKNAME_REQUIRED" def __init__(self, suggested: str, message: str | None = None) -> None: super().__init__( message or f"Ник «{suggested}» уже занят — выберите другой.", details={"suggested": suggested}, ) class FactionNotAvailableError(ValidationError): code = "FACTION_NOT_AVAILABLE" def __init__(self) -> None: super().__init__("Фракция недоступна выбранной группе.") class DuplicateParticipantError(ValidationError): code = "DUPLICATE_PARTICIPANT" def __init__(self) -> None: super().__init__("Игрок или фракция повторяются в партии.") class InvalidRankingError(ValidationError): code = "INVALID_RANKING" def __init__(self, message: str = "Некорректная расстановка мест.") -> None: super().__init__(message) class InvalidCredentialsError(AuthError): code = "INVALID_CREDENTIALS" def __init__(self) -> None: super().__init__("Неверный логин или пароль.") class TooManyAttemptsError(AppError): """Слишком много неудачных попыток ввода пароля. retry_after — через сколько секунд ближайшая попытка снова будет принята.""" status_code = 429 code = "TOO_MANY_ATTEMPTS" def __init__(self, retry_after: int) -> None: minutes = max(1, -(-retry_after // 60)) super().__init__( f"Слишком много неудачных попыток. Повторите через {minutes} мин.", details={"retry_after": retry_after}, ) class TelegramAlreadyLinkedError(ConflictError): code = "TELEGRAM_ALREADY_LINKED" def __init__(self) -> None: super().__init__("К аккаунту уже привязан Telegram.") class TelegramTakenError(ConflictError): code = "TELEGRAM_TAKEN" def __init__(self) -> None: super().__init__("Этот Telegram уже привязан к другому аккаунту.") async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: return exc.to_response()