Обработка ошибок и HTTPException

HTTPException — это способ прервать обработку и вернуть клиенту корректный HTTP-статус с понятным сообщением; кастомные обработчики позволяют единообразно превращать любые исключения в ответы.

Ошибка — это тоже часть API-контракта. Клиент должен получить не «500 что-то сломалось», а осмысленный код (404, 403, 409) и структурированное тело с описанием проблемы.

Когда ресурс не найден, прав не хватает или данные конфликтуют, нужно сообщить об этом честно. Бросать обычное Exception нельзя — оно превратится в 500. Вместо этого FastAPI предлагает HTTPException с нужным кодом и деталями. Фреймворк поймает его и сформирует корректный ответ с правильным статусом и JSON-телом.

from fastapi import FastAPI, HTTPException, status

app = FastAPI()

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    user = db.get(user_id)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Пользователь {user_id} не найден",
        )
    return user

# кастомный обработчик исключения домена
from fastapi.responses import JSONResponse

class OutOfStock(Exception):
    pass

@app.exception_handler(OutOfStock)
async def out_of_stock_handler(request, exc):
    return JSONResponse(status_code=409, content={"detail": "товар закончился"})

Два уровня работы с ошибками. Точечно — raise HTTPException(...) прямо в обработчике для конкретного случая. Системно — @app.exception_handler(SomeError) ловит исключения определённого типа по всему приложению и единообразно превращает их в ответы. Второй подход хорош для доменных исключений: бизнес-логика бросает OutOfStock, не зная про HTTP, а обработчик переводит это в код 409.

Как работает под капотом

FastAPI оборачивает обработку запроса в перехват исключений: поймал HTTPException — взял его status_code и detail, собрал JSON-ответ; поймал тип с зарегистрированным обработчиком — вызвал его. Смоделируем диспетчеризацию ошибок на stdlib:

class HTTPException(Exception):
    def __init__(self, status_code, detail):
        self.status_code, self.detail = status_code, detail

class OutOfStock(Exception):
    pass

handlers = {OutOfStock: lambda exc: (409, {"detail": "товар закончился"})}

def process(handler):
    try:
        return handler()
    except HTTPException as e:                      # встроенный перехват
        return e.status_code, {"detail": e.detail}
    except Exception as e:
        for typ, fn in handlers.items():            # кастомные обработчики
            if isinstance(e, typ):
                return fn(e)
        return 500, {"detail": "Internal Server Error"}

print(process(lambda: (_ for _ in ()).throw(HTTPException(404, "не найдено"))))
print(process(lambda: (_ for _ in ()).throw(OutOfStock())))
print(process(lambda: {"ok": True}))

Попробуй сам ▶ Три исхода: HTTPException → точный статус, доменное исключение → кастомный обработчик, успех → результат. Так FastAPI и маршрутизирует ошибки.

Частые ошибки

Первая — бросать «голое» Exception вместо HTTPException, отдавая клиенту 500 вместо осмысленного кода. Вторая — возвращать ошибку в произвольном формате, ломая единый контракт (клиент ждёт поле detail). Третья — раскрывать в detail внутренние подробности (трассировки, SQL), что небезопасно. Четвёртая — использовать неподходящие коды (404 там, где нужен 403, или 400 вместо 422).

Best practices

  • Точечные случаи — HTTPException с правильным кодом и человекочитаемым detail.
  • Доменные исключения переводите в HTTP через @app.exception_handler, не смешивая бизнес-логику с HTTP.
  • Не раскрывайте внутренние детали в ответах об ошибке.
  • Подбирайте коды по семантике: 404 — нет ресурса, 403 — нет прав, 409 — конфликт, 422 — невалидные данные.

Глобальная нормализация ошибок

Зрелое API отдаёт все ошибки в едином формате, чтобы клиент мог обрабатывать их одинаково. Для этого переопределяют обработчики не только своих доменных исключений, но и встроенных: обработчик ошибок валидации (RequestValidationError) и общий обработчик HTTPException. Так вы приводите и 422 от Pydantic, и ваши 404/403, и неожиданные сбои к одной структуре — например, { "error": { "code": ..., "message": ..., "details": ... } }. Это резко упрощает фронтенд, которому больше не нужно угадывать форму ответа по коду. Отдельно стоит позаботиться о непредвиденных исключениях: их ловят на верхнем уровне, логируют с трассировкой (но не показывают её клиенту) и отдают обезличенную 500. Грамотная обработка ошибок — это не разрозненные raise по коду, а продуманная политика, где каждый класс проблем имеет понятный, единообразный и безопасный ответ.

Итог: ошибки — часть контракта. HTTPException даёт точечные корректные ответы, а кастомные обработчики единообразно переводят доменные исключения в HTTP. Подбирайте коды осмысленно и не раскрывайте внутренности.

Проверьте себя
1. Что вернёт клиенту raise HTTPException(status_code=404, detail='не найдено')?
A500 с трассировкой
BОтвет со статусом 404 и JSON-телом {'detail': 'не найдено'}
CПустой ответ 200
DРедирект
2. Зачем регистрировать @app.exception_handler для доменного исключения вроде OutOfStock?
AЧтобы ускорить запросы
BЧтобы бизнес-логика бросала доменное исключение, не зная про HTTP, а обработчик единообразно переводил его в нужный код статуса
CЭто обязательно для всех исключений
DЧтобы отключить валидацию