Обработка ошибок в FastAPI: что учесть на практике
FastAPI из коробки отдаёт 422 на невалидный запрос и 500 на необработанное исключение — и оба ответа часто бесполезны для клиента. Разбираем, как выстроить обработку ошибок так, чтобы фронтенд получал понятные сообщения, логи не превращались в кашу, а пользователь не видел трейсбек.

Проблема: стандартные ответы FastAPI недостаточны
Вы подключили Pydantic-модель к эндпоинту, отправили запрос с опечаткой в поле — и получили ответ 422 с массивом detail, где каждый элемент содержит loc, msg и type. Формат задокументирован, но клиентские команды всё равно жалуются: непонятно, что именно поправить, сообщения на английском, а структура отличается от того, что приходит при бизнес-ошибках. Ещё хуже — необработанное исключение внутри хендлера: FastAPI вернёт голый 500 и Internal Server Error, а в логах окажется трейсбек без контекста запроса.
Ниже — четыре подхода к обработке ошибок, которые решают конкретные боли: единый формат для клиента, гранулярное логирование, разделение валидационных и бизнес-ошибок, корректное поведение в фоновых задачах. Всё написано для FastAPI 0.100+ и Pydantic v2.
Единый формат ошибки
Первый источник путаницы — разные структуры ответов. Валидация возвращает массив объектов, HTTPException — строку или словарь в detail, а непойманное исключение — просто текст. Фронтенд вынужден разбирать три формата вместо одного.
Решение — зафиксировать контракт ошибки и заставить все обработчики его соблюдать:
# error_schema.py — единая схема ошибки (FastAPI 0.100+, Pydantic v2)
from pydantic import BaseModel
class ErrorDetail(BaseModel):
code: str # машиночитаемый код: "validation_error", "not_found"
message: str # человекочитаемое сообщение
field: str | None = None # имя поля, если ошибка привязана к полю
class ErrorResponse(BaseModel):
errors: list[ErrorDetail]
Теперь любой ответ с кодом 4xx или 5xx возвращает {"errors": [...]}. Клиент парсит один интерфейс, независимо от причины.
Переопределяем обработчик валидации
FastAPI позволяет заменить встроенный обработчик RequestValidationError. Вот как привести его к общему формату:
# handlers.py
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from starlette.responses import JSONResponse
from error_schema import ErrorDetail, ErrorResponse
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(RequestValidationError)
async def validation_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
details = []
for err in exc.errors():
# loc — кортеж вида ("body", "username") или ("query", "page")
field = ".".join(str(part) for part in err["loc"] if part != "body")
details.append(
ErrorDetail(
code="validation_error",
message=err["msg"],
field=field or None,
)
)
body = ErrorResponse(errors=details)
return JSONResponse(status_code=422, content=body.model_dump())
После этого клиент на запрос с невалидным username получит не «value is not a valid email address», завёрнутый в незнакомую структуру, а объект с полем field: "username" и понятным code.
Бизнес-ошибки: свой класс исключений
Валидация — это входные данные. Но есть ошибки уровня логики: «товар закончился», «нельзя отменить оплаченный заказ», «пользователь заблокирован». Вставлять их через HTTPException удобно, но теряется машиночитаемый код, и бизнес-логика начинает знать про HTTP-статусы.
# exceptions.py
class AppError(Exception):
"""Базовое бизнес-исключение."""
def __init__(self, code: str, message: str, status: int = 400):
self.code = code
self.message = message
self.status = status
class NotFoundError(AppError):
def __init__(self, entity: str, entity_id: str | int):
super().__init__(
code="not_found",
message=f"{entity} с id={entity_id} не найден",
status=404,
)
class ConflictError(AppError):
def __init__(self, message: str):
super().__init__(code="conflict", message=message, status=409)
Обработчик для AppError регистрируется один раз:
# внутри register_error_handlers
@app.exception_handler(AppError)
async def app_error_handler(
request: Request, exc: AppError
) -> JSONResponse:
body = ErrorResponse(
errors=[ErrorDetail(code=exc.code, message=exc.message)]
)
return JSONResponse(status_code=exc.status, content=body.model_dump())
Теперь в сервисном слое вы пишете raise NotFoundError("Order", order_id) и не думаете про JSON — обработчик сделает всё сам.
Ловушка для непойманных исключений
Любое исключение, которое не попало ни в один обработчик, превращается в 500 без тела. Это плохо дважды: клиент не получает формат ErrorResponse, а в логах — голый трейсбек без идентификатора запроса.
import uuid
import logging
logger = logging.getLogger("app.errors")
# внутри register_error_handlers
@app.exception_handler(Exception)
async def unhandled_handler(
request: Request, exc: Exception
) -> JSONResponse:
error_id = uuid.uuid4().hex[:12]
logger.exception(
"Необработанная ошибка [%s] %s %s",
error_id,
request.method,
request.url.path,
)
body = ErrorResponse(
errors=[
ErrorDetail(
code="internal_error",
message=f"Внутренняя ошибка. Идентификатор: {error_id}",
)
]
)
return JSONResponse(status_code=500, content=body.model_dump())
Идентификатор error_id связывает ответ клиенту и запись в логе. Поддержка ищет по нему за секунды, а не по таймстампу и описанию «у меня ошибка».
Трейсбек — для разработчика, а не для пользователя. Единственное, что клиент должен получить при 500, — идентификатор инцидента и фраза «мы уже знаем».
Сравнение подходов
| Подход | Когда использовать | Плюсы | Минусы |
|---|---|---|---|
Стандартный HTTPException | Прототип, внутренний сервис | Ноль кода, знакомый формат | Нет машиночитаемого кода, формат отличается от валидации |
Свой AppError + единый формат | Проект с фронтендом или мобильным клиентом | Единый контракт, бизнес-логика не знает про HTTP | Нужна дисциплина: все ошибки — через свои классы |
| Middleware для логирования | Любой проект на проде | Централизованный лог, error_id | Middleware не ловит ошибки в WebSocket и StreamingResponse |
| Sentry / аналоги | Продакшен с командой | Группировка, алерты, контекст | Внешняя зависимость, стоимость на больших объёмах |
Частые ловушки
BackgroundTasks и потерянные исключения
Если вы добавляете фоновую задачу через BackgroundTasks, исключение внутри неё не попадёт в ваш exception_handler — ответ к тому моменту уже отправлен. Ошибка окажется только в stderr Uvicorn. Оборачивайте тело фоновой задачи в try/except с явным логированием или используйте очередь задач (Celery, arq), где ретраи и мониторинг встроены.
Переопределение обработчика и порядок регистрации
FastAPI и Starlette выбирают обработчик по MRO* класса исключения. Если вы зарегистрировали обработчик для Exception, он перехватит всё, включая HTTPException от Starlette. Регистрируйте обработчики от конкретного к общему: сначала RequestValidationError, потом AppError, потом HTTPException из Starlette, и только в конце — Exception.
* MRO (Method Resolution Order) — порядок, в котором Python обходит иерархию классов при поиске метода или, в данном случае, при сопоставлении исключения с обработчиком.
422 вместо 400 — это нормально?
FastAPI возвращает 422 (Unprocessable Entity) при ошибке валидации, следуя спецификации OpenAPI. Некоторые команды переопределяют на 400 «для совместимости». Если вы это делаете — будьте последовательны и обновите OpenAPI-схему, иначе автогенерированные клиенты начнут расходиться с реальностью.
Когда что выбирать
- Один бэкенд, один фронтенд — хватит
AppError+ переопределённый валидатор + глобальныйExceptionhandler. Три обработчика, один формат. - Микросервисы — добавьте трассировку: передавайте
trace_idиз заголовка запроса вerror_id, чтобы ошибка прослеживалась через всю цепочку. - Публичный API — документируйте формат
ErrorResponseв OpenAPI черезresponsesпараметр роутера. Иначе потребитель API узнает о формате ошибки только в рантайме.
Что делать дальше
После настройки обработчиков стоит закрыть два смежных вопроса. Первый — мониторинг: подключите Sentry или аналог и убедитесь, что error_id передаётся как тег, по которому можно искать. Второй — тестирование ошибок: напишите параметризованные тесты через httpx.AsyncClient, которые проверяют не только статус-код, но и структуру ErrorResponse для каждого типа ошибки. Без таких тестов первый же рефакторинг сломает контракт, и фронтенд узнает об этом в проде.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: FastAPI — Handling Errors (официальная документация), Starlette — Exception Handlers
Частые вопросы
Зачем переопределять стандартную обработку ошибок FastAPI, если она и так возвращает JSON?
Можно ли вернуть 400 вместо 422 при ошибке валидации?
Что делать, если ошибка возникает в BackgroundTasks?
Как обработчик ошибок работает, если у меня другая версия FastAPI или Pydantic v1?
Не замедлит ли глобальный обработчик Exception каждый запрос?
Как не потерять контекст запроса в логах ошибок?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.