Обработка ошибок: exception handlers
Когда что-то пошло не так, API должен отвечать понятной ошибкой в едином формате — а не «500 Internal Server Error» с трейсбэком.
Exception handler — это функция, которую FastAPI вызывает, когда в обработке запроса возникает исключение определённого типа; она превращает исключение в корректный HTTP-ответ с нужным кодом и телом.
Хороший API предсказуем в ошибках: клиент всегда получает JSON одной и той же формы — будь то «не найдено», «нет прав» или «невалидные данные». Это позволяет фронтенду писать единый код разбора ошибок, а не угадывать формат для каждого случая. FastAPI даёт три уровня: готовый HTTPException для типовых случаев, свои классы исключений для доменных ошибок и глобальные обработчики, задающие единый формат на всё приложение.
HTTPException — базовый инструмент
Самый прямой способ вернуть ошибку — выбросить HTTPException с кодом и сообщением. FastAPI сам превратит его в ответ:
from fastapi import FastAPI, HTTPException
app = FastAPI()
users = {1: "Ada"}
@app.get("/users/{user_id}")
def get_user(user_id: int):
if user_id not in users:
raise HTTPException(status_code=404, detail="Пользователь не найден")
return {"id": user_id, "name": users[user_id]}
Тело ответа будет {"detail": "Пользователь не найден"} с кодом 404. В detail можно положить и словарь — тогда в JSON попадёт структура. Дополнительно HTTPException умеет ставить заголовки ответа — например, WWW-Authenticate для 401:
raise HTTPException(
status_code=401,
detail="Неверный токен",
headers={"WWW-Authenticate": "Bearer"},
)
Кастомные исключения
В большом приложении удобнее бросать доменные исключения, не зная про HTTP. Бизнес-логика в services/ не должна импортировать FastAPI — она просто выбрасывает свою ошибку:
// app/exceptions.py
class AppError(Exception):
"""Базовая ошибка приложения."""
class UserNotFound(AppError):
def __init__(self, user_id: int):
self.user_id = user_id
class InsufficientFunds(AppError):
def __init__(self, needed: int, available: int):
self.needed = needed
self.available = available
Теперь сервис бросает UserNotFound(42), ничего не зная о кодах HTTP, — а перевод в ответ возьмёт на себя обработчик.
Регистрация обработчика: @app.exception_handler
Декоратор @app.exception_handler(КлассИсключения) связывает тип исключения с функцией-обработчиком. Она получает запрос и само исключение и возвращает ответ:
from fastapi import Request
from fastapi.responses import JSONResponse
from app.exceptions import UserNotFound, InsufficientFunds
@app.exception_handler(UserNotFound)
async def handle_user_not_found(request: Request, exc: UserNotFound):
return JSONResponse(
status_code=404,
content={"error": "user_not_found", "user_id": exc.user_id},
)
@app.exception_handler(InsufficientFunds)
async def handle_funds(request: Request, exc: InsufficientFunds):
return JSONResponse(
status_code=409,
content={"error": "insufficient_funds",
"needed": exc.needed, "available": exc.available},
)
Теперь где угодно в коде можно raise UserNotFound(42) — FastAPI поймает исключение, найдёт зарегистрированный обработчик и вернёт аккуратный {"error": "user_not_found", "user_id": 42} с кодом 404. Доменная логика и представление ошибки разведены.
Обработка ошибок валидации
Когда тело запроса не проходит проверку Pydantic, FastAPI по умолчанию отвечает кодом 422 и подробным списком ошибок. Этот формат можно переопределить, перехватив RequestValidationError — например, чтобы он совпадал с вашим единым форматом ошибок:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def handle_validation(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={"error": "validation_error", "details": exc.errors()},
)
Метод exc.errors() возвращает машиночитаемый список: какое поле, что не так, какое значение пришло. Обернув его в свой ключ error, вы делаете ошибки валидации неотличимыми по форме от остальных ошибок API.
Единый формат ошибок
Цель — чтобы любая ошибка имела одинаковую структуру, скажем {"error": <код>, "message": <текст>}. Соберём вспомогательную функцию и покажем идею на исполнимом Python, не поднимая сервер:
def error_body(code, message, **extra):
body = {"error": code, "message": message}
body.update(extra)
return body
print(error_body("user_not_found", "Пользователь не найден", user_id=42))
print(error_body("insufficient_funds", "Недостаточно средств",
needed=100, available=30))
print(error_body("validation_error", "Ошибка валидации"))
Вывод:
{'error': 'user_not_found', 'message': 'Пользователь не найден', 'user_id': 42}
{'error': 'insufficient_funds', 'message': 'Недостаточно средств', 'needed': 100, 'available': 30}
{'error': 'validation_error', 'message': 'Ошибка валидации'}
Каждый обработчик строит тело через error_body(...) — и весь API отвечает в одном стиле. Клиенту достаточно прочитать поле error, чтобы понять, что случилось.
Глобальная страховка от 500
Непредвиденные исключения (баг в коде) превращаются в 500. Чтобы и они отдавали единый JSON, а не утекали трейсбэком, вешают обработчик на базовый Exception — но внутри обязательно логируют настоящую ошибку, иначе вы её потеряете:
import logging
logger = logging.getLogger("app")
@app.exception_handler(Exception)
async def handle_unexpected(request: Request, exc: Exception):
logger.exception("Необработанная ошибка") # трейсбэк в лог
return JSONResponse(
status_code=500,
content={"error": "internal_error",
"message": "Что-то пошло не так"},
)
Как это работает под капотом
FastAPI (через Starlette) хранит словарь «тип исключения → обработчик». Когда в ходе запроса вылетает исключение, фреймворк ищет в этом словаре наиболее подходящий тип — точное совпадение или базовый класс, — и вызывает связанную функцию вместо обычного 500. HTTPException и RequestValidationError просто зарегистрированы там по умолчанию; ваши декораторы @app.exception_handler добавляют или переопределяют записи. Поиск идёт по дереву наследования, поэтому обработчик на AppError поймает и UserNotFound, и InsufficientFunds (они наследники) — если для них нет более точного обработчика. Важная тонкость: исключения, выброшенные внутри middleware (а не в эндпоинте/зависимости), этот механизм может не перехватить, ведь часть стека ошибок обрабатывается на другом слое — поэтому критичную обработку ошибок держат в обработчиках и зависимостях, а не в middleware.
Частые ошибки
- Ловят всё подряд в обработчике на
Exception, но не логируютexc— настоящая причина бага теряется, остаётся только «internal_error». - Возвращают из обработчика
dictвместоJSONResponseи забывают проstatus_code— ответ уходит с кодом 200, хотя это ошибка. - Бросают
HTTPExceptionиз слоя бизнес-логики (services/), привязывая его к HTTP; чище бросать доменное исключение и переводить его в обработчике. - Переопределили
RequestValidationError, но потерялиexc.errors()— клиент больше не понимает, какое поле невалидно. - Регистрируют обработчик на слишком общий базовый класс раньше частных и удивляются, что частный не срабатывает; учитывайте иерархию исключений.
Итоги
HTTPException(status_code, detail, headers)— быстрый способ вернуть типовую ошибку с кодом и заголовками.- Доменные ошибки лучше выражать своими классами исключений, не зная про HTTP в бизнес-логике.
@app.exception_handler(Класс)связывает тип исключения с функцией, возвращающейJSONResponse.- Перехват
RequestValidationErrorпозволяет привести ошибки валидации (422) к единому формату; данные берут изexc.errors(). - Единый формат ошибок + обработчик на
Exceptionс логированием делают API предсказуемым и отлаживаемым.