Обработка ошибок в FastAPI: семь способов и единый формат ответа
FastAPI даёт как минимум семь способов вернуть ошибку клиенту, но если не договориться о формате заранее, фронтенд получит то JSON с полем detail, то голый текст, то HTML-страницу от Uvicorn. Разбираем каждый способ, сравниваем и выбираем тот, который не развалится, когда API вырастет.

Проблема: три формата ошибок из одного API
Типичная ситуация: эндпоинт валидации возвращает {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}, кастомный обработчик — {"error": "not_found"}, а необработанное исключение — HTML-страницу «Internal Server Error» от Uvicorn. Фронтенд-разработчик пишет три ветки парсинга, потом четвёртую, потом перестаёт верить документации. Проблема не в FastAPI, а в том, что команда не выбрала один подход и не закрыла все точки выхода ошибок.
Единый конверт: о чём договориться до кода
Прежде чем сравнивать способы, зафиксируйте формат. Один из самых распространённых вариантов — RFC 9457 (Problem Details for HTTP APIs). Но можно взять и свой конверт, если он покрывает три вещи:
- машиночитаемый код ошибки (строка, а не HTTP-статус — статусов мало, ошибок много);
- человекочитаемое сообщение;
- контекст: какие поля не прошли валидацию, какой ресурс не найден, какой лимит превышен.
Пример конверта, который используется дальше:
{
"ok": false,
"error": {
"code": "validation_error",
"message": "Некорректные данные запроса",
"details": [
{"field": "email", "reason": "Не является адресом электронной почты"}
]
}
}
Успешные ответы тоже оборачиваются: {"ok": true, "result": {...}}. Так клиент всегда парсит одну и ту же структуру.
Семь способов вернуть ошибку
1. Прямой raise HTTPException
Самый короткий путь. FastAPI ловит HTTPException и отдаёт JSON с полем detail.
from fastapi import HTTPException
# FastAPI 0.100+
raise HTTPException(status_code=404, detail="Заказ не найден")
Плюс: ноль настроек. Минус: формат {"detail": "..."} зашит в фреймворк. Если вы договорились о своём конверте, каждый raise придётся оборачивать — или менять обработчик глобально (см. способ 4).
2. JSONResponse напрямую
Возвращаете ответ руками, без исключения.
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=422,
content={"ok": False, "error": {"code": "validation_error", "message": "..."}},
)
Плюс: полный контроль над телом. Минус: теряется документация OpenAPI — FastAPI не знает, что эндпоинт может вернуть 422 с таким телом. Придётся добавлять responses={422: {"model": ErrorEnvelope}} к каждому роуту вручную.
3. Кастомное исключение + exception_handler
Создаёте свой класс исключения и регистрируете обработчик на уровне приложения.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AppError(Exception):
def __init__(self, code: str, message: str, status: int = 400, details: list | None = None):
self.code = code
self.message = message
self.status = status
self.details = details or []
app = FastAPI()
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status,
content={
"ok": False,
"error": {
"code": exc.code,
"message": exc.message,
"details": exc.details,
},
},
)
Теперь в любом месте кода: raise AppError("order_not_found", "Заказ не найден", status=404). Формат един, логика в одном месте.
4. Переопределение встроенных обработчиков
FastAPI регистрирует обработчики для HTTPException и RequestValidationError. Их можно заменить, чтобы даже встроенные ошибки шли в вашем конверте.
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"ok": False,
"error": {"code": "http_error", "message": str(exc.detail), "details": []},
},
)
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
details = [
{"field": ".".join(str(loc) for loc in e["loc"]), "reason": e["msg"]}
for e in exc.errors()
]
return JSONResponse(
status_code=422,
content={
"ok": False,
"error": {"code": "validation_error", "message": "Некорректные данные запроса", "details": details},
},
)
Важный нюанс: импортировать нужно именно StarletteHTTPException из starlette.exceptions, а не из fastapi. FastAPI-шный HTTPException наследует от него, и если зарегистрировать обработчик только для дочернего класса, ошибки от middleware и Starlette пролетят мимо.
5. Middleware для отлова необработанных исключений
Способы 3 и 4 не ловят Exception, который вылетел из зависимости или ORM. Чтобы клиент никогда не получил HTML от Uvicorn, добавьте middleware.
import traceback
@app.middleware("http")
async def catch_unhandled(request: Request, call_next):
try:
return await call_next(request)
except Exception:
# Логируйте traceback, но не показывайте клиенту
traceback.print_exc()
return JSONResponse(
status_code=500,
content={
"ok": False,
"error": {"code": "internal_error", "message": "Внутренняя ошибка сервера", "details": []},
},
)
Подводный камень: в некоторых версиях Starlette middleware http-типа не перехватывает исключения, выброшенные до формирования Response — они ловятся на уровне ASGI-сервера. Надёжнее использовать чистый ASGI-middleware или обработчик Exception через @app.exception_handler(Exception) (но помните: он перехватит всё, включая SystemExit).
6. Depends + результат-объект вместо исключений
Альтернативный стиль, вдохновлённый Result-типами. Зависимость возвращает либо данные, либо ошибку, а обработчик роута проверяет результат.
from dataclasses import dataclass
from typing import TypeVar, Generic
T = TypeVar("T")
@dataclass
class Ok(Generic[T]):
value: T
@dataclass
class Err:
code: str
message: str
status: int = 400
Result = Ok[T] | Err
Роут получает Result от сервисного слоя и сам решает, что вернуть. Исключения не летят через стек. Минус: в каждом роуте — проверка if isinstance(result, Err), код становится многословным. Работает хорошо, когда бизнес-логика вынесена в сервисы и вы хотите, чтобы сервисный слой не зависел от FastAPI вообще.
7. Pydantic-модель ошибки + responses в OpenAPI
Не столько способ вернуть ошибку, сколько способ задокументировать её. Объявляете Pydantic-модель ответа и указываете в параметре responses роута.
from pydantic import BaseModel
class ErrorDetail(BaseModel):
field: str
reason: str
class ErrorBody(BaseModel):
code: str
message: str
details: list[ErrorDetail] = []
class ErrorEnvelope(BaseModel):
ok: bool = False
error: ErrorBody
@app.get(
"/orders/{order_id}",
responses={404: {"model": ErrorEnvelope, "description": "Заказ не найден"}},
)
async def get_order(order_id: int):
...
Теперь Swagger UI покажет клиенту точную схему ошибки. Этот способ комбинируется с любым из предыдущих — он про контракт, а не про механику.
Сравнение подходов
| Способ | Единый формат | OpenAPI-схема | Ловит всё | Объём кода |
|---|---|---|---|---|
| 1. raise HTTPException | Нет | Частично | Нет | Минимальный |
| 2. JSONResponse напрямую | Да, вручную | Нет без responses | Нет | Средний |
| 3. Кастомное исключение | Да | Нет без responses | Только свои | Средний |
| 4. Переопределение встроенных | Да | Частично | HTTP + валидация | Средний |
| 5. Middleware / catch-all | Да | Нет | Да | Малый |
| 6. Result-объект | Да | Нет без responses | Нет | Большой |
| 7. Pydantic + responses | — | Да | — | Средний |
Какой выбирать
Ни один способ не работает в одиночку. Рабочая комбинация для проекта, который дорос до нескольких десятков эндпоинтов:
- Кастомное исключение (3) — для всех бизнес-ошибок. Один класс, один обработчик.
- Переопределение встроенных обработчиков (4) — чтобы валидационные ошибки Pydantic и стандартные HTTP-ошибки шли в том же конверте.
- Catch-all middleware или exception_handler(Exception) (5) — чтобы необработанные исключения не утекали в HTML.
- Pydantic-модель + responses (7) — чтобы клиент видел контракт в Swagger.
Если ваш API отдаёт ошибки в трёх разных форматах, у вас не «гибкая архитектура» — у вас три бага, которые фронтенд обходит тремя костылями.
Result-объект (6) имеет смысл, когда сервисный слой переиспользуется между FastAPI и, скажем, CLI-командами или Celery-задачами. Если весь код живёт внутри FastAPI — это лишняя церемония.
Когда подход ломается
WebSocket
Обработчики exception_handler не перехватывают исключения внутри WebSocket-эндпоинтов. Для них нужен отдельный try/except внутри хендлера с отправкой JSON-сообщения через websocket.send_json().
StreamingResponse
Если ошибка возникла после начала стриминга, HTTP-статус уже отправлен. Клиент получит обрыв потока, а не ваш конверт. Единственный выход — валидировать всё до начала генератора.
Ошибки на уровне ASGI-сервера
Таймауты Uvicorn, обрыв соединения, слишком большой запрос — всё это происходит до FastAPI. Ваш middleware их не увидит. Настраивайте лимиты на уровне reverse-proxy (Nginx, Caddy) и следите за их форматом ответа отдельно.
Сноска: RFC 9457 (ранее RFC 7807) — стандарт IETF, описывающий формат ответа об ошибке в HTTP API. Определяет поля type, title, status, detail, instance. Не обязателен, но если ваш API будут потреблять внешние команды — общепринятый формат снимает вопросы на старте интеграции.
Что делать дальше
- Зафиксируйте формат конверта в Pydantic-модели и сгенерируйте из неё клиентские типы (openapi-typescript-codegen или аналог).
- Добавьте в CI тест, который проходит по всем роутам и проверяет, что любой ответ с кодом 4xx/5xx парсится в
ErrorEnvelope. - Настройте структурированное логирование: пишите
error.codeиrequest_idв каждый лог ошибки — это ускорит отладку в проде в разы. - Посмотрите, как ваш reverse-proxy отдаёт свои ошибки (502, 504) — если он возвращает HTML, клиент снова получит неожиданный формат.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: FastAPI — Handling Errors (официальная документация), RFC 9457 — Problem Details for HTTP APIs (IETF)
Частые вопросы
Обязательно ли оборачивать успешные ответы в конверт {ok: true, result: ...}?
Что если у меня другая версия FastAPI или Starlette — код из статьи сработает?
Как не потерять traceback при catch-all обработчике?
Как протестировать, что все эндпоинты возвращают ошибки в едином формате?
Result-объект вместо исключений — это не оверинжиниринг?
Как быть с ошибками, которые приходят от Nginx, а не от FastAPI?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.