Обработка ошибок
В этом разделе описывается система обработки ошибок в Boilerplate. Вы узнаете о стандартных ошибках FastAPI, пользовательских исключениях и о том, как создать единообразную обработку ошибок в вашем API.
Обзор обработки ошибок
FastAPI предоставляет встроенные механизмы для обработки различных типов ошибок:
- Ошибки валидации данных — когда входные данные не соответствуют ожидаемым схемам
- HTTP-исключения — для возврата соответствующих HTTP-статусов и сообщений об ошибках
- Обработчики исключений — глобальные или специфичные обработчики для разных типов ошибок
Стандартные HTTP-исключения
FastAPI предоставляет класс HTTPException
для возврата HTTP-ошибок:
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/items/{item_id}")
async def read_item(item_id: int):
if item_id < 0:
raise HTTPException(
status_code=400,
detail="ID элемента не может быть отрицательным"
)
item = await get_item(item_id)
if item is None:
raise HTTPException(
status_code=404,
detail=f"Элемент с ID {item_id} не найден",
headers={"X-Error": "Item not found"} # Дополнительные заголовки
)
return item
Кастомные исключения
В Boilerplate определены пользовательские исключения для унификации обработки ошибок:
# app/core/exceptions.py
from typing import Any, Dict, Optional
from fastapi import HTTPException, status
class NotFoundError(HTTPException):
"""
Исключение для ошибки "Не найдено" (HTTP 404)
"""
def __init__(
self,
detail: str = "Запрашиваемый ресурс не найден",
headers: Optional[Dict[str, Any]] = None
):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
headers=headers
)
class BadRequestError(HTTPException):
"""
Исключение для ошибки "Неверный запрос" (HTTP 400)
"""
def __init__(
self,
detail: str = "Неверный запрос",
headers: Optional[Dict[str, Any]] = None
):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail,
headers=headers
)
class UnauthorizedError(HTTPException):
"""
Исключение для ошибки "Неавторизован" (HTTP 401)
"""
def __init__(
self,
detail: str = "Не авторизован",
headers: Optional[Dict[str, Any]] = None
):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers=headers or {"WWW-Authenticate": "Bearer"}
)
class ForbiddenError(HTTPException):
"""
Исключение для ошибки "Доступ запрещен" (HTTP 403)
"""
def __init__(
self,
detail: str = "Нет прав доступа",
headers: Optional[Dict[str, Any]] = None
):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=detail,
headers=headers
)
class ConflictError(HTTPException):
"""
Исключение для ошибки "Конфликт" (HTTP 409)
"""
def __init__(
self,
detail: str = "Конфликт с текущим состоянием ресурса",
headers: Optional[Dict[str, Any]] = None
):
super().__init__(
status_code=status.HTTP_409_CONFLICT,
detail=detail,
headers=headers
)
Использование кастомных исключений в коде:
# app/api/routes/users.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app import crud
from app.api import deps
from app.core.exceptions import NotFoundError, ConflictError
router = APIRouter()
@router.get("/{user_id}")
async def get_user(
user_id: int,
db: AsyncSession = Depends(deps.get_db),
):
user = await crud.user.get(db, id=user_id)
if not user:
raise NotFoundError(detail=f"Пользователь с ID {user_id} не найден")
return user
@router.post("/")
async def create_user(
user_in: schemas.UserCreate,
db: AsyncSession = Depends(deps.get_db),
):
existing_user = await crud.user.get_by_email(db, email=user_in.email)
if existing_user:
raise ConflictError(detail="Пользователь с таким email уже существует")
# Создание пользователя
return await crud.user.create(db, obj_in=user_in)
Глобальные обработчики исключений
FastAPI позволяет определить глобальные обработчики для разных типов исключений. Это позволяет унифицировать формат ответов с ошибками:
# app/core/exception_handlers.py
from typing import Any, Dict, Union
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from sqlalchemy.exc import SQLAlchemyError
from starlette.exceptions import HTTPException
from app.core.config import settings
def add_exception_handlers(app: FastAPI) -> None:
"""
Добавляет глобальные обработчики исключений к FastAPI приложению.
"""
app.add_exception_handler(
HTTPException, http_exception_handler
)
app.add_exception_handler(
RequestValidationError, validation_exception_handler
)
app.add_exception_handler(
SQLAlchemyError, sqlalchemy_exception_handler
)
app.add_exception_handler(
Exception, general_exception_handler
)
async def http_exception_handler(
request: Request, exc: HTTPException
) -> JSONResponse:
"""
Обработчик для HTTP исключений.
"""
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"error": {
"code": exc.status_code,
"message": exc.detail,
"type": "http_error"
}
},
headers=exc.headers,
)
async def validation_exception_handler(
request: Request, exc: Union[RequestValidationError, ValidationError]
) -> JSONResponse:
"""
Обработчик для ошибок валидации.
"""
errors = []
for error in exc.errors():
error_info = {
"loc": error.get("loc", []),
"msg": error.get("msg", ""),
"type": error.get("type", "")
}
errors.append(error_info)
return JSONResponse(
status_code=422,
content={
"success": False,
"error": {
"code": 422,
"message": "Ошибка валидации данных",
"type": "validation_error",
"details": errors
}
},
)
async def sqlalchemy_exception_handler(
request: Request, exc: SQLAlchemyError
) -> JSONResponse:
"""
Обработчик для ошибок базы данных.
"""
error_message = str(exc)
# В продакшене скрываем детали SQL ошибок
if not settings.DEBUG:
error_message = "Ошибка базы данных"
return JSONResponse(
status_code=500,
content={
"success": False,
"error": {
"code": 500,
"message": error_message,
"type": "database_error"
}
},
)
async def general_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
"""
Обработчик для всех остальных типов исключений.
"""
error_message = str(exc)
# В продакшене скрываем детали внутренних ошибок
if not settings.DEBUG:
error_message = "Внутренняя ошибка сервера"
return JSONResponse(
status_code=500,
content={
"success": False,
"error": {
"code": 500,
"message": error_message,
"type": "server_error"
}
},
)
Регистрация обработчиков исключений в приложении:
# app/main.py
from fastapi import FastAPI
from app.core.exception_handlers import add_exception_handlers
from app.api.api import api_router
app = FastAPI(
title="Boilerplate API",
description="Документация API для Boilerplate",
version="0.1.0",
)
# Добавляем обработчики исключений
add_exception_handlers(app)
# Регистрация маршрутов API
app.include_router(api_router, prefix="/api/v1")
Обработка ошибок валидации
FastAPI автоматически валидирует входящие данные с помощью Pydantic. В случае ошибок валидации возвращается ответ с кодом 422 Unprocessable Entity и подробными сведениями об ошибках.
Пример ответа сервера при ошибке валидации:
{
"success": false,
"error": {
"code": 422,
"message": "Ошибка валидации данных",
"type": "validation_error",
"details": [
{
"loc": ["body", "email"],
"msg": "значение не является допустимым адресом электронной почты",
"type": "value_error.email"
},
{
"loc": ["body", "password"],
"msg": "длина строки должна быть не менее 8 символов",
"type": "value_error.any_str.min_length"
}
]
}
}
Обработка исключений в бизнес-логике
Рекомендуется создать сервисный слой для инкапсуляции бизнес-логики и обработки исключений:
# app/services/user_service.py
from typing import Any, Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app import crud, models, schemas
from app.core.exceptions import NotFoundError, ConflictError
class UserService:
async def get_user(self, db: AsyncSession, user_id: int) -> models.User:
"""
Получение пользователя по ID с обработкой ошибок.
"""
user = await crud.user.get(db, id=user_id)
if not user:
raise NotFoundError(detail=f"Пользователь с ID {user_id} не найден")
return user
async def create_user(
self, db: AsyncSession, user_in: schemas.UserCreate
) -> models.User:
"""
Создание пользователя с обработкой ошибок.
"""
# Проверяем, существует ли пользователь с таким email
existing_user = await crud.user.get_by_email(db, email=user_in.email)
if existing_user:
raise ConflictError(detail="Пользователь с таким email уже существует")
# Создаем пользователя
return await crud.user.create(db, obj_in=user_in)
async def update_user(
self,
db: AsyncSession,
user_id: int,
user_update: schemas.UserUpdate
) -> models.User:
"""
Обновление пользователя с обработкой ошибок.
"""
# Проверяем, существует ли пользователь
user = await self.get_user(db, user_id)
# Если обновляется email, проверяем его уникальность
if user_update.email and user_update.email != user.email:
existing_user = await crud.user.get_by_email(db, email=user_update.email)
if existing_user:
raise ConflictError(detail="Пользователь с таким email уже существует")
# Обновляем пользователя
return await crud.user.update(db, db_obj=user, obj_in=user_update)
async def delete_user(self, db: AsyncSession, user_id: int) -> models.User:
"""
Удаление пользователя с обработкой ошибок.
"""
# Проверяем, существует ли пользователь
user = await self.get_user(db, user_id)
# Удаляем пользователя
return await crud.user.remove(db, id=user_id)
user_service = UserService()
Использование сервиса в маршрутах API:
# app/api/routes/users.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.api import deps
from app.services.user_service import user_service
router = APIRouter()
@router.get("/{user_id}", response_model=schemas.User)
async def get_user(
user_id: int,
db: AsyncSession = Depends(deps.get_db),
):
"""
Получение пользователя по ID.
"""
return await user_service.get_user(db, user_id)
@router.post("/", response_model=schemas.User, status_code=status.HTTP_201_CREATED)
async def create_user(
user_in: schemas.UserCreate,
db: AsyncSession = Depends(deps.get_db),
):
"""
Создание нового пользователя.
"""
return await user_service.create_user(db, user_in)
@router.put("/{user_id}", response_model=schemas.User)
async def update_user(
user_id: int,
user_update: schemas.UserUpdate,
db: AsyncSession = Depends(deps.get_db),
):
"""
Обновление пользователя.
"""
return await user_service.update_user(db, user_id, user_update)
@router.delete("/{user_id}", response_model=schemas.User)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(deps.get_db),
):
"""
Удаление пользователя.
"""
return await user_service.delete_user(db, user_id)
Логирование ошибок
Важно правильно логировать ошибки для отладки и мониторинга:
# app/core/logging.py
import logging
import sys
from typing import Any, Dict, List
from loguru import logger
from loguru._defaults import LOGURU_FORMAT
from app.core.config import settings
class InterceptHandler(logging.Handler):
"""
Обработчик перехвата для интеграции со стандартным логированием Python.
"""
def emit(self, record: logging.LogRecord) -> None:
# Получаем соответствующий уровень Loguru
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
# Находим вызывающий код
frame, depth = logging.currentframe(), 2
while frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
def setup_logging() -> None:
"""
Настройка логирования приложения.
"""
# Удаляем все обработчики по умолчанию
logging.root.handlers = [InterceptHandler()]
logging.root.setLevel(logging.INFO)
# Удаляем существующие логгеры
for name in logging.root.manager.loggerDict.keys():
logging.getLogger(name).handlers = []
logging.getLogger(name).propagate = True
# Настройка Loguru
logger.configure(
handlers=[
{
"sink": sys.stdout,
"level": logging.DEBUG if settings.DEBUG else logging.INFO,
"format": LOGURU_FORMAT,
},
{
"sink": "logs/app.log",
"level": logging.INFO,
"format": LOGURU_FORMAT,
"rotation": "10 MB",
"retention": "1 month",
"compression": "zip",
}
]
)
# Добавляем контекстную информацию к логам
logger.configure(
extra={"application": "boilerplate"}
)
# Функция для дополнительной информации в логах ошибок
def log_error(
error: Exception,
request_info: Dict[str, Any] = None,
context: Dict[str, Any] = None
) -> None:
"""
Логирование ошибки с дополнительной контекстной информацией.
"""
error_data = {
"error_type": type(error).__name__,
"error_message": str(error),
}
if request_info:
error_data["request"] = request_info
if context:
error_data["context"] = context
logger.error(f"Error: {error}", error_data)
Пример использования логирования в обработчике исключений:
# Дополненный обработчик общих исключений
from app.core.logging import log_error
async def general_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
"""
Обработчик для всех остальных типов исключений.
"""
# Логируем ошибку с информацией о запросе
request_info = {
"method": request.method,
"url": str(request.url),
"client": request.client.host if request.client else "unknown",
"headers": dict(request.headers),
}
log_error(exc, request_info=request_info)
# Формируем ответ
error_message = str(exc)
if not settings.DEBUG:
error_message = "Внутренняя ошибка сервера"
return JSONResponse(
status_code=500,
content={
"success": False,
"error": {
"code": 500,
"message": error_message,
"type": "server_error"
}
},
)
Советы и лучшие практики
- Используйте стандартные HTTP-коды для разных типов ошибок (400, 401, 403, 404, 409, 422, 500 и т.д.)
- Создавайте пользовательские исключения для специфических случаев
- Предоставляйте подробную информацию об ошибках в режиме разработки, но скрывайте её в продакшене
- Включайте уникальные идентификаторы для каждой ошибки, чтобы упростить их отслеживание в логах
- Логируйте все неожиданные ошибки для последующего анализа
- Используйте надёжные инструменты мониторинга (Sentry, ELK, Prometheus)
- Предоставляйте понятные сообщения об ошибках пользователям