feat: add custom exceptions

This commit is contained in:
2026-05-09 16:25:31 +03:00
parent bedce9e910
commit 9d56b7f6f5
17 changed files with 214 additions and 13 deletions

View File

@@ -1 +1,9 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException 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

View File

@@ -15,4 +15,4 @@ class ApplicationException(Exception):
self.headers = headers self.headers = headers
def __str__(self): def __str__(self):
return f"{self.status_code}: {self.message}" return f'{self.status_code}: {self.message}'

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -4,6 +4,7 @@ import secrets
from typing import AsyncGenerator from typing import AsyncGenerator
from fastapi import Depends, FastAPI, status from fastapi import Depends, FastAPI, status
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html 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.responses import HTMLResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from starlette.middleware.cors import CORSMiddleware 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.logger import logger
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.presentation.dependencies import get_rabbit 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.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
from src.presentation.routing import v1_router from src.presentation.routing import v1_router
from src.presentation.schemas import ErrorResponse
security = HTTPBasic() 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: async def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> HTTPBasicCredentials:
user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME) user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME)
@@ -152,3 +206,6 @@ async def ping() -> dict[str, str]:
'message': 'pong', 'message': 'pong',
'status': 'ok', 'status': 'ok',
} }
app.openapi = custom_openapi

View File

@@ -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

View File

@@ -1,17 +1,15 @@
from fastapi.responses import ORJSONResponse
from fastapi import Request from fastapi import Request
from fastapi.responses import ORJSONResponse
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException
async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse: async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse:
detail = exc.message detail = exc.message
if 500 <= exc.status_code: if 500 <= exc.status_code:
detail = "Internal Server Error" detail = 'Internal Server Error'
return ORJSONResponse( return ORJSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={"detail": detail}, content={'detail': detail},
headers=dict(exc.headers) if exc.headers else None, headers=dict(exc.headers) if exc.headers else None,
) )

View File

@@ -1,5 +1,5 @@
from fastapi.responses import ORJSONResponse
from fastapi import Request from fastapi import Request
from fastapi.responses import ORJSONResponse
from starlette import status from starlette import status
from src.infrastructure.logger import logger from src.infrastructure.logger import logger
@@ -9,4 +9,4 @@ async def unhandled_exception_handler(_request: Request, exc: Exception) -> ORJS
return ORJSONResponse( return ORJSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'detail': 'Internal Server Error'}, content={'detail': 'Internal Server Error'},
) )

View File

@@ -1,2 +0,0 @@
from src.presentation.handlers.unhandled_handler import unhandled_exception_handler
from src.presentation.handlers.application_handler import application_exception_handler

View File

@@ -1 +1,5 @@
from src.presentation.schemas.user import RegistrationStart, RegistrationComplete, UserLogin, LoginStart 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

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
from pydantic import Field
class ErrorResponse(BaseModel):
detail: str = Field(title='Detail')