Перейти к содержимому

Практика 5. Практическая работа 5. Коды состояния и обработка ошибок

Цель

  1. Научиться возвращать корректные коды состояния (2xx/4xx/5xx) под каждую ситуацию.
  2. Освоить HTTPException для типовых ошибок (404, 400, 409).
  3. Реализовать кастомные обработчики через @app.exception_handler и собственные классы исключений.
  4. Привести все ошибки API к единому формату тела ответа.
  5. Перехватить и переформатировать ошибку валидации (422) так, чтобы сообщения были понятными.

Теория

Код состояния — трёхзначное число в статус-строке ответа, сообщающее результат обработки запроса (см. лекцию 3). Классы: 2xx — успех, 4xx — ошибка клиента, 5xx — ошибка сервера. Главное правило: код должен отражать результат. Возвращать 200 OK с телом {"error": ...} — грубая ошибка.

Часто используемые коды: 200 — успешное чтение/обновление; 201 — ресурс создан (POST); 204 — успех без тела (часто после DELETE); 400 — нарушение бизнес-правила; 404 — не найдено; 409 — конфликт (дубликат); 422 — данные не прошли валидацию (Pydantic); 500 — внутренняя ошибка.

HTTPException — встроенный механизм FastAPI: raise HTTPException(status_code=..., detail=...) прерывает обработчик и отдаёт клиенту ответ с нужным кодом.

Кастомные обработчики (@app.exception_handler) перехватывают исключение определённого типа и формируют ответ централизованно. Это позволяет завести единый формат тела ошибки (поля code, message, details, path) на весь сервис и переформатировать встроенную ошибку валидации RequestValidationError (по умолчанию 422) в понятный вид (см. лекцию 6, разделы 4.1–4.3).


Задание

Создаём сервис заказов. Стартовый файл main.py с моделями и хранилищем в памяти:

from typing import Optional
from uuid import uuid4, UUID
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
app = FastAPI(title="Orders API (Practice 5)")
class OrderCreate(BaseModel):
product: str = Field(..., min_length=1, description="Название товара")
quantity: int = Field(..., gt=0, description="Количество, > 0")
price: float = Field(..., gt=0, description="Цена за единицу, > 0")
class Order(OrderCreate):
id: UUID
DB: dict[UUID, Order] = {}

Шаг 1. Единый формат тела ошибки

Опишите функцию-помощник, формирующую тело ошибки одинаковой структуры для всего API.

def error_body(code: str, message: str, request: Request, details=None):
return {
"error": {
"code": code, # машиночитаемый код
"message": message, # понятный человеку текст
"details": details, # подробности (опционально)
"path": str(request.url.path),
}
}

Шаг 2. Корректные коды для CRUD

Реализуйте эндпойнты так, чтобы каждый возвращал правильный код. Обратите внимание на status_code в декораторе.

@app.post("/orders/", response_model=Order, status_code=status.HTTP_201_CREATED)
def create_order(payload: OrderCreate):
new_id = uuid4()
order = Order(id=new_id, **payload.model_dump())
DB[new_id] = order
return order # 201 Created
@app.get("/orders/{order_id}", response_model=Order)
def get_order(order_id: UUID):
order = DB.get(order_id)
if order is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Заказ не найден",
)
return order # 200 OK
@app.delete("/orders/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_order(order_id: UUID):
if order_id not in DB:
raise HTTPException(status_code=404, detail="Заказ не найден")
del DB[order_id]
return # 204 No Content

Шаг 3. Бизнес-правила через HTTPException

Добавьте проверки, нарушающие которые возвращают 400 и 409. Сообщения detail — понятные.

MAX_TOTAL = 1_000_000
@app.post("/orders/checked/", response_model=Order, status_code=201)
def create_checked(payload: OrderCreate):
# бизнес-правило: общая сумма не больше лимита
if payload.quantity * payload.price > MAX_TOTAL:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Сумма заказа превышает лимит {MAX_TOTAL}",
)
# конфликт: такой товар уже заказан
if any(o.product == payload.product for o in DB.values()):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Заказ на этот товар уже существует",
)
new_id = uuid4()
order = Order(id=new_id, **payload.model_dump())
DB[new_id] = order
return order

Шаг 4. Глобальный обработчик HTTPException

Чтобы HTTPException отдавался в едином формате (а не как {"detail": ...} по умолчанию), зарегистрируйте обработчик. Перехватываем StarletteHTTPException — базовый класс для всех HTTP-ошибок FastAPI.

from starlette.exceptions import HTTPException as StarletteHTTPException
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(
status_code=exc.status_code,
content=error_body("HTTP_ERROR", str(exc.detail), request),
)

Шаг 5. Обработка ошибок валидации (422)

По умолчанию FastAPI отдаёт 422 со списком ошибок Pydantic. Переформатируйте его в единый вид и оставьте понятные подробности по полям.

@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
details = [
{"field": ".".join(str(p) for p in e["loc"] if p != "body"),
"reason": e["msg"]}
for e in exc.errors()
]
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=error_body("VALIDATION_ERROR",
"Ошибка валидации данных", request, details),
)

Проверьте: отправьте POST /orders/ с quantity: 0 или без поля price — вместо стандартного 422 придёт ваш единый формат с понятными details.

Шаг 6. Собственный класс исключения

Заведите доменное исключение и обработчик к нему. Это удобно, когда одна и та же ошибка возникает в разных эндпойнтах.

class OrderNotFound(Exception):
def __init__(self, order_id: UUID):
self.order_id = order_id
@app.exception_handler(OrderNotFound)
async def order_not_found_handler(request: Request, exc: OrderNotFound):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content=error_body("ORDER_NOT_FOUND",
f"Заказ {exc.order_id} не найден", request),
)
@app.patch("/orders/{order_id}", response_model=Order)
def update_qty(order_id: UUID, quantity: int):
order = DB.get(order_id)
if order is None:
raise OrderNotFound(order_id) # вместо HTTPException
updated = order.model_copy(update={"quantity": quantity})
DB[order_id] = updated
return updated

Запуск и проверка

Окно терминала
uvicorn main:app --reload

Откройте Swagger UI (http://127.0.0.1:8000/docs) и проверьте сценарии:

  • создание заказа → 201;
  • получение несуществующего → 404 в едином формате;
  • удаление → 204 без тела;
  • превышение лимита → 400, дубликат → 409;
  • невалидное тело (quantity: 0) → 422 с понятными details.

Критерии оценки

  • 20% — все CRUD-эндпойнты возвращают корректные коды (200/201/204).
  • 20%HTTPException используется для 404, 400, 409 с понятными сообщениями.
  • 20% — реализован единый формат тела ошибки (code, message, details, path).
  • 20% — зарегистрирован обработчик RequestValidationError, ответ 422 переформатирован.
  • 10% — заведён и обработан собственный класс исключения.
  • 10% — проверены позитивные и негативные сценарии в /docs, сообщения понятны.

Вопросы для самопроверки

  1. Почему нельзя возвращать ошибку с кодом 200 OK? Какой код подходит для несуществующего ресурса?
  2. Чем отличаются коды 400, 404, 409 и 422? Приведите по примеру.
  3. Что делает raise HTTPException(...) и что увидит клиент по умолчанию?
  4. Зачем нужен единый формат тела ошибки и какие поля он обычно содержит?
  5. Какое исключение FastAPI генерирует при провале валидации Pydantic и какой код по умолчанию ему соответствует?
  6. Как @app.exception_handler помогает централизовать обработку ошибок?
  7. В чём преимущество собственного класса исключения перед прямым вызовом HTTPException в каждом эндпойнте?