From 9d56b7f6f50f3199b3c4f0af6048baae47a5d900 Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Sat, 9 May 2026 16:25:31 +0300 Subject: [PATCH] feat: add custom exceptions --- src/application/domain/exceptions/__init__.py | 10 +++- ...exceptions.py => application_exception.py} | 2 +- .../exceptions/bad_request_exception.py | 16 +++++ .../domain/exceptions/conflict_exception.py | 16 +++++ .../domain/exceptions/forbidden_exception.py | 16 +++++ .../exceptions/internal_server_exception.py | 16 +++++ .../domain/exceptions/not_found_exception.py | 16 +++++ .../service_unavailable_exception.py | 16 +++++ .../exceptions/too_many_requests_exception.py | 16 +++++ .../exceptions/unauthorized_exception.py | 16 +++++ src/main.py | 59 ++++++++++++++++++- src/presentation/handler/__init__.py | 2 + .../application_exception_handler.py} | 8 +-- .../unhandled_exception_handler.py} | 4 +- src/presentation/handlers/__init__.py | 2 - src/presentation/schemas/__init__.py | 6 +- src/presentation/schemas/error.py | 6 ++ 17 files changed, 214 insertions(+), 13 deletions(-) rename src/application/domain/exceptions/{application_exceptions.py => application_exception.py} (88%) create mode 100644 src/application/domain/exceptions/bad_request_exception.py create mode 100644 src/application/domain/exceptions/conflict_exception.py create mode 100644 src/application/domain/exceptions/forbidden_exception.py create mode 100644 src/application/domain/exceptions/internal_server_exception.py create mode 100644 src/application/domain/exceptions/not_found_exception.py create mode 100644 src/application/domain/exceptions/service_unavailable_exception.py create mode 100644 src/application/domain/exceptions/too_many_requests_exception.py create mode 100644 src/application/domain/exceptions/unauthorized_exception.py create mode 100644 src/presentation/handler/__init__.py rename src/presentation/{handlers/application_handler.py => handler/application_exception_handler.py} (84%) rename src/presentation/{handlers/unhandled_handler.py => handler/unhandled_exception_handler.py} (98%) delete mode 100644 src/presentation/handlers/__init__.py create mode 100644 src/presentation/schemas/error.py diff --git a/src/application/domain/exceptions/__init__.py b/src/application/domain/exceptions/__init__.py index 6d6ca18..5305794 100644 --- a/src/application/domain/exceptions/__init__.py +++ b/src/application/domain/exceptions/__init__.py @@ -1 +1,9 @@ -from src.application.domain.exceptions.application_exceptions import ApplicationException \ No newline at end of file +from src.application.domain.exceptions.application_exception import ApplicationException +from src.application.domain.exceptions.bad_request_exception import BadRequestException +from src.application.domain.exceptions.conflict_exception import ConflictException +from src.application.domain.exceptions.forbidden_exception import ForbiddenException +from src.application.domain.exceptions.internal_server_exception import InternalServerException +from src.application.domain.exceptions.not_found_exception import NotFoundException +from src.application.domain.exceptions.service_unavailable_exception import ServiceUnavailableException +from src.application.domain.exceptions.too_many_requests_exception import TooManyRequestsException +from src.application.domain.exceptions.unauthorized_exception import UnauthorizedException \ No newline at end of file diff --git a/src/application/domain/exceptions/application_exceptions.py b/src/application/domain/exceptions/application_exception.py similarity index 88% rename from src/application/domain/exceptions/application_exceptions.py rename to src/application/domain/exceptions/application_exception.py index 5006dee..7396ceb 100644 --- a/src/application/domain/exceptions/application_exceptions.py +++ b/src/application/domain/exceptions/application_exception.py @@ -15,4 +15,4 @@ class ApplicationException(Exception): self.headers = headers def __str__(self): - return f"{self.status_code}: {self.message}" + return f'{self.status_code}: {self.message}' diff --git a/src/application/domain/exceptions/bad_request_exception.py b/src/application/domain/exceptions/bad_request_exception.py new file mode 100644 index 0000000..10eb9be --- /dev/null +++ b/src/application/domain/exceptions/bad_request_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class BadRequestException(ApplicationException): + def __init__( + self, + message: str = 'Bad Request', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/conflict_exception.py b/src/application/domain/exceptions/conflict_exception.py new file mode 100644 index 0000000..b276930 --- /dev/null +++ b/src/application/domain/exceptions/conflict_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class ConflictException(ApplicationException): + def __init__( + self, + message: str = 'Conflict', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_409_CONFLICT, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/forbidden_exception.py b/src/application/domain/exceptions/forbidden_exception.py new file mode 100644 index 0000000..fe24741 --- /dev/null +++ b/src/application/domain/exceptions/forbidden_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class ForbiddenException(ApplicationException): + def __init__( + self, + message: str = 'Forbidden', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/internal_server_exception.py b/src/application/domain/exceptions/internal_server_exception.py new file mode 100644 index 0000000..eae4f00 --- /dev/null +++ b/src/application/domain/exceptions/internal_server_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class InternalServerException(ApplicationException): + def __init__( + self, + message: str = 'Internal Server Error', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/not_found_exception.py b/src/application/domain/exceptions/not_found_exception.py new file mode 100644 index 0000000..175686b --- /dev/null +++ b/src/application/domain/exceptions/not_found_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class NotFoundException(ApplicationException): + def __init__( + self, + message: str = 'Not Found', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/service_unavailable_exception.py b/src/application/domain/exceptions/service_unavailable_exception.py new file mode 100644 index 0000000..067a638 --- /dev/null +++ b/src/application/domain/exceptions/service_unavailable_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class ServiceUnavailableException(ApplicationException): + def __init__( + self, + message: str = 'Service Unavailable', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/too_many_requests_exception.py b/src/application/domain/exceptions/too_many_requests_exception.py new file mode 100644 index 0000000..9b88c3c --- /dev/null +++ b/src/application/domain/exceptions/too_many_requests_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class TooManyRequestsException(ApplicationException): + def __init__( + self, + message: str = 'Too Many Requests', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/unauthorized_exception.py b/src/application/domain/exceptions/unauthorized_exception.py new file mode 100644 index 0000000..364caa4 --- /dev/null +++ b/src/application/domain/exceptions/unauthorized_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class UnauthorizedException(ApplicationException): + def __init__( + self, + message: str = 'Unauthorized', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + message=message, + headers=headers, + ) diff --git a/src/main.py b/src/main.py index c51f260..b9921d9 100644 --- a/src/main.py +++ b/src/main.py @@ -4,6 +4,7 @@ import secrets from typing import AsyncGenerator from fastapi import Depends, FastAPI, status from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.openapi.utils import get_openapi from fastapi.responses import HTMLResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from starlette.middleware.cors import CORSMiddleware @@ -15,13 +16,66 @@ from src.infrastructure.utils import generate_instance_id from src.infrastructure.logger import logger from src.infrastructure.config import settings from src.presentation.dependencies import get_rabbit -from src.presentation.handlers import application_exception_handler, unhandled_exception_handler +from src.presentation.handler import application_exception_handler +from src.presentation.handler import unhandled_exception_handler from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware from src.presentation.routing import v1_router +from src.presentation.schemas import ErrorResponse security = HTTPBasic() +ERROR_RESPONSES: dict[int, str] = { + status.HTTP_400_BAD_REQUEST: 'Bad Request', + status.HTTP_401_UNAUTHORIZED: 'Unauthorized', + status.HTTP_403_FORBIDDEN: 'Forbidden', + status.HTTP_404_NOT_FOUND: 'Not Found', + status.HTTP_409_CONFLICT: 'Conflict', + status.HTTP_429_TOO_MANY_REQUESTS: 'Too Many Requests', + status.HTTP_500_INTERNAL_SERVER_ERROR: 'Internal Server Error', + status.HTTP_503_SERVICE_UNAVAILABLE: 'Service Unavailable', +} + + +def custom_openapi() -> dict: + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + license_info=app.license_info, + ) + components = openapi_schema.setdefault('components', {}) + schemas = components.setdefault('schemas', {}) + schemas['ErrorResponse'] = ErrorResponse.model_json_schema() + + for path_item in openapi_schema.get('paths', {}).values(): + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + + responses = operation.setdefault('responses', {}) + for status_code, description in ERROR_RESPONSES.items(): + responses.setdefault( + str(status_code), + { + 'description': description, + 'content': { + 'application/json': { + 'schema': { + '$ref': '#/components/schemas/ErrorResponse', + }, + }, + }, + }, + ) + + app.openapi_schema = openapi_schema + return app.openapi_schema + async def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> HTTPBasicCredentials: user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME) @@ -152,3 +206,6 @@ async def ping() -> dict[str, str]: 'message': 'pong', 'status': 'ok', } + + +app.openapi = custom_openapi diff --git a/src/presentation/handler/__init__.py b/src/presentation/handler/__init__.py new file mode 100644 index 0000000..b844c75 --- /dev/null +++ b/src/presentation/handler/__init__.py @@ -0,0 +1,2 @@ +from src.presentation.handler.application_exception_handler import application_exception_handler +from src.presentation.handler.unhandled_exception_handler import unhandled_exception_handler diff --git a/src/presentation/handlers/application_handler.py b/src/presentation/handler/application_exception_handler.py similarity index 84% rename from src/presentation/handlers/application_handler.py rename to src/presentation/handler/application_exception_handler.py index aa68716..d6ce09e 100644 --- a/src/presentation/handlers/application_handler.py +++ b/src/presentation/handler/application_exception_handler.py @@ -1,17 +1,15 @@ -from fastapi.responses import ORJSONResponse from fastapi import Request +from fastapi.responses import ORJSONResponse from src.application.domain.exceptions import ApplicationException async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse: detail = exc.message if 500 <= exc.status_code: - detail = "Internal Server Error" + detail = 'Internal Server Error' return ORJSONResponse( status_code=exc.status_code, - content={"detail": detail}, + content={'detail': detail}, headers=dict(exc.headers) if exc.headers else None, ) - - diff --git a/src/presentation/handlers/unhandled_handler.py b/src/presentation/handler/unhandled_exception_handler.py similarity index 98% rename from src/presentation/handlers/unhandled_handler.py rename to src/presentation/handler/unhandled_exception_handler.py index c6f1d52..1249344 100644 --- a/src/presentation/handlers/unhandled_handler.py +++ b/src/presentation/handler/unhandled_exception_handler.py @@ -1,5 +1,5 @@ -from fastapi.responses import ORJSONResponse from fastapi import Request +from fastapi.responses import ORJSONResponse from starlette import status from src.infrastructure.logger import logger @@ -9,4 +9,4 @@ async def unhandled_exception_handler(_request: Request, exc: Exception) -> ORJS return ORJSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={'detail': 'Internal Server Error'}, - ) \ No newline at end of file + ) diff --git a/src/presentation/handlers/__init__.py b/src/presentation/handlers/__init__.py deleted file mode 100644 index cb6cbad..0000000 --- a/src/presentation/handlers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from src.presentation.handlers.unhandled_handler import unhandled_exception_handler -from src.presentation.handlers.application_handler import application_exception_handler \ No newline at end of file diff --git a/src/presentation/schemas/__init__.py b/src/presentation/schemas/__init__.py index 257f9cf..f5bf55c 100644 --- a/src/presentation/schemas/__init__.py +++ b/src/presentation/schemas/__init__.py @@ -1 +1,5 @@ -from src.presentation.schemas.user import RegistrationStart, RegistrationComplete, UserLogin, LoginStart \ No newline at end of file +from src.presentation.schemas.error import ErrorResponse +from src.presentation.schemas.user import RegistrationComplete +from src.presentation.schemas.user import RegistrationStart +from src.presentation.schemas.user import LoginStart +from src.presentation.schemas.user import UserLogin \ No newline at end of file diff --git a/src/presentation/schemas/error.py b/src/presentation/schemas/error.py new file mode 100644 index 0000000..539d35b --- /dev/null +++ b/src/presentation/schemas/error.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel +from pydantic import Field + + +class ErrorResponse(BaseModel): + detail: str = Field(title='Detail')