Практика 5. Практическая работа 5. Коды состояния и обработка ошибок
Цель
- Научиться возвращать корректные коды состояния (
2xx/4xx/5xx) под каждую ситуацию. - Освоить
HTTPExceptionдля типовых ошибок (404,400,409). - Реализовать кастомные обработчики через
@app.exception_handlerи собственные классы исключений. - Привести все ошибки API к единому формату тела ответа.
- Перехватить и переформатировать ошибку валидации (
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 Optionalfrom uuid import uuid4, UUID
from fastapi import FastAPI, HTTPException, Request, statusfrom fastapi.exceptions import RequestValidationErrorfrom fastapi.responses import JSONResponsefrom 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, сообщения понятны.
Вопросы для самопроверки
- Почему нельзя возвращать ошибку с кодом
200 OK? Какой код подходит для несуществующего ресурса? - Чем отличаются коды
400,404,409и422? Приведите по примеру. - Что делает
raise HTTPException(...)и что увидит клиент по умолчанию? - Зачем нужен единый формат тела ошибки и какие поля он обычно содержит?
- Какое исключение FastAPI генерирует при провале валидации Pydantic и какой код по умолчанию ему соответствует?
- Как
@app.exception_handlerпомогает централизовать обработку ошибок? - В чём преимущество собственного класса исключения перед прямым вызовом
HTTPExceptionв каждом эндпойнте?