Обработка ошибок: 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 предсказуемым и отлаживаемым.
Проверьте себя
1. Вы пишете сервис в app/services/, не зависящий от FastAPI. Как ему лучше всего сообщать об ошибке «пользователь не найден»?
AБросать доменное исключение (например, UserNotFound), а перевод в HTTP-ответ делать в @app.exception_handler
BБросать HTTPException(404) прямо из сервиса
CВозвращать None и проверять его в каждом эндпоинте
DПечатать ошибку в лог и возвращать пустой словарь
2. Зачем перехватывать `RequestValidationError` своим обработчиком?
AЧтобы привести ответ об ошибке валидации (422) к единому формату всего API, взяв детали из exc.errors()
BЧтобы отключить валидацию Pydantic полностью
CЧтобы валидация выполнялась быстрее
DБез этого FastAPI не возвращает 422 вообще
3. Что важно сделать в глобальном обработчике, повешенном на базовый класс `Exception`?
AЗалогировать настоящее исключение (logger.exception), прежде чем вернуть клиенту обобщённый ответ 500
BВернуть клиенту полный трейсбэк, чтобы было удобнее отлаживать
CПодавить исключение и вернуть код 200
DПеребросить исключение дальше без обработки