← назад к разделу

PYTHON · REST API Фаза 14. API: REST и контракты

Обязательно

HTTP-заголовки: идемпотентность и трассировка

Header · Idempotency-Key · traceparent

Заголовки переносят метаданные запроса: кто клиент, как читать тело, безопасен ли повтор и куда идти дальше.

1. Header превращает строку в тип

1 / 5

x_request_id в Python соответствует X-Request-ID в HTTP. Header() читает значение, response.headers добавляет его к ответу.

X-Request-ID7f3a...Header()x_request_id: strResponsesame header

2. Content-Type и Location описывают результат

2 / 5

Content-Type говорит, как разобрать тело, Authorization подтверждает личность, Location после 201 указывает адрес созданного ресурса, ETag версию ответа.

Content-Typeapplication/jsonAuthorizationBearer tokenLocation/orders/42ETag"order-v17"

3. Idempotency-Key делает повтор безопасным

3 / 5

Клиент повторяет тот же ключ после таймаута, сервер возвращает сохранённый результат вместо второго списания. Другой ключ или другое тело - другая операция.

POST + key K-17retry + key K-17Stored resultorder 42один платёж201same body

4. traceparent связывает цепочку вызовов

4 / 5

Один trace-id проходит API, сервис заказов и базу, новый span-id обозначает каждый шаг. По нему трейс собирается без догадок по времени.

Clienttrace-id AOrdersspan-id BBillingspan-id Ctraceparent: 00-A-B-01

5. ETag и Cache-Control управляют свежестью

5 / 5

If-None-Match позволяет ответить 304 без тела, private и no-store ограничивают кеширование персональных данных. CORS отдельно разрешает браузеру читать ответ.

GET /orders/42If-None-Match: "v17"ETag = "v17"данные не изменились304без тела

Когда клиент шлёт запрос, он передаёт не только тело — но и заголовки: кто он, что ожидает получить, есть ли у него токен. Сервер отвечает тем же: что вернул, можно ли кешировать, где найти созданный ресурс. Разберём, как FastAPI работает с заголовками: стандартными и своими.

Что такое HTTP-заголовки

Заголовок — это пара «имя: значение» в самом начале запроса или ответа, до тела. Например:

живой пример

GET /api/v1/orders/123
Authorization: Bearer eyJhbGci...
Accept: application/json
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Тело запроса — это данные. Заголовки — это мета-информация: кто запрашивает, в каком формате, есть ли кеш, нужна ли трассировка.

В FastAPI заголовки объявляются прямо в сигнатуре функции, и фреймворк автоматически включает их в OpenAPI-документацию. Это удобнее, чем описывать их вручную в YAML.

Как читать заголовок в FastAPI

Используется Header(...) из fastapi. FastAPI автоматически превращает snake_case-параметр в kebab-case заголовок: if_none_match читает заголовок If-None-Match.

from fastapi import APIRouter, Header, Response
from typing import Annotated

router = APIRouter(prefix="/api/v1", tags=["Orders"])


@router.get("/orders/{order_id}", operation_id="getOrder")
async def get_order(
    order_id: str,
    authorization: Annotated[str, Header()],
    if_none_match: Annotated[str | None, Header()] = None,
) -> Response:
    etag = '"33a64df5"'
    if if_none_match == etag:
        return Response(status_code=304)

    order = await order_service.get(order_id)
    return Response(
        content=order.model_dump_json(exclude_none=True),
        media_type="application/json",
        headers={"ETag": etag, "Cache-Control": "private, max-age=60"},
    )

Content-Type FastAPI разбирает сам — объявлять его через параметр не нужно.

Стандартные заголовки и их назначение

Есть заголовки, смысл которых зафиксирован стандартом HTTP. Их нельзя использовать под другие цели:

ЗаголовокЧто делает
Authorizationтокен доступа, всегда со схемой: Bearer eyJ...
Acceptкакой формат ответа ожидает клиент
ETagверсия ресурса — клиент сохраняет и посылает обратно при следующем запросе
If-None-Match«верни данные, только если версия изменилась»
If-Match«обнови только если версия совпадает с моей» (защита от одновременных правок)
Cache-Controlможно ли кешировать ответ и как долго
LocationURL только что созданного ресурса при ответе 201

Заголовок Location при создании ресурса

Когда сервер возвращает 201 Created, хорошим тоном является указать в Location, где найти созданный объект. FastAPI это не делает автоматически — нужно добавить явно:

@router.post("/orders", status_code=201, operation_id="createOrder")
async def create_order(
    body: CreateOrderRequest,
    response: Response,
) -> OrderResponse:
    order = await order_service.create(body)
    response.headers["Location"] = f"/api/v1/orders/{order.order_id}"
    return order

Инъекция response: Response позволяет добавить заголовки, не заменяя тело — Pydantic-модель сериализуется штатно.

Свои заголовки — с доменным префиксом

Иногда нужно передать что-то специфичное для вашей системы: идентификатор запроса, версию клиента, код арендатора. Для этого придумывают кастомные заголовки.

Раньше было принято добавлять префикс X- (например, X-Request-Id). В 2012 году RFC 6648 признал эту практику устаревшей: X- ничего не гарантирует, только вносит путаницу.

Правильный подход — выбрать один доменный префикс для всех сервисов проекта и использовать только его:

Sber-Request-Id: 550e8400-e29b-41d4-a716-446655440000
Sber-Client-Version: 3.2.1
Sber-Tenant-Id: corporate

В FastAPI такие заголовки объявляются в snake_case, а фреймворк сам преобразует их в kebab-case:

@router.get("/products/{product_id}", operation_id="getProduct")
async def get_product(
    product_id: UUID,
    sber_request_id: Annotated[str | None, Header()] = None,
    sber_client_version: Annotated[str | None, Header()] = None,
) -> ProductResponse:
    return await product_service.get(product_id)

sber_request_id → FastAPI читает заголовок Sber-Request-Id. Никакого alias вручную.

Idempotency-Key — безопасный повтор запроса

Представьте: пользователь нажал «Оплатить», запрос ушёл, но ответ потерялся в сети. Приложение не знает — заказ создан или нет? Если просто повторить запрос, можно создать заказ дважды.

Решение — Idempotency-Key. Клиент генерирует уникальный ключ для каждой бизнес-операции (не для каждого HTTP-запроса) и посылает его в заголовке. Сервер запоминает: «под этим ключом уже выполнена операция, вот результат» — и при повторном запросе возвращает тот же результат без повторного действия.

живой пример

POST /api/v1/orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{ "customerId": "cust-123", "items": [...] }
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Правила работы с ключом:

  • клиент генерирует ключ один раз на операцию;
  • повторный POST с тем же ключом → сервер возвращает первый результат, заказ не создаётся снова;
  • тот же ключ с другим телом → 409 Conflict.

В FastAPI Idempotency-Key объявляется как обязательный заголовок:

@router.post("/orders", status_code=201, operation_id="createOrder")
async def create_order(
    body: CreateOrderRequest,
    response: Response,
    idempotency_key: Annotated[UUID, Header()],
) -> OrderResponse:
    order = await order_service.create(body, idempotency_key=str(idempotency_key))
    response.headers["Location"] = f"/api/v1/orders/{order.order_id}"
    return order

Тип UUID — FastAPI проверит формат до вызова функции. Если заголовок не передан, вернётся ошибка валидации.

Idempotency-Key применяется только к POST и PATCH — операциям, которые создают или изменяют данные. На GET и DELETE он не нужен: повторный GET не создаёт ничего нового.

traceparent — нить через несколько сервисов

Когда запрос проходит через несколько сервисов подряд, сложно понять: где именно что-то пошло не так? Чтобы это видеть, каждый сервис передаёт следующему специальный заголовок traceparent — он несёт единый идентификатор всей цепочки вызовов.

Формат стандартизирован W3C Trace Context:

traceparent: 00 - 1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c - 7a8b9c0d1e2f3a4b - 01 версия trace-id, 32 знака parent-id, 16 знаков флаги

Заголовок один на всю цепочку вызовов: по trace-id собираются все участки одного запроса, а span-id говорит, чьим потомком будет следующий вызов. Сервис обязан передать его дальше — иначе цепочка обрывается на нём.

  • trace-id — уникальный ID всей цепочки. Одинаковый от первого сервиса до последнего.
  • parent-id — ID текущего шага (span). Меняется на каждом сервисе.
  • флаги — 01 означает «этот запрос трассируется».

Подключение OpenTelemetry

Самый удобный способ — библиотека opentelemetry-instrumentation-fastapi. Она сама перехватывает входящий traceparent, создаёт span и передаёт контекст в исходящие запросы:

from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)

После подключения:

  • если клиент прислал traceparent — сервис продолжает ту же цепочку;
  • если заголовка не было — генерируется новый trace-id.

Текущий trace-id доступен в коде:

from opentelemetry import trace

def get_trace_id() -> str | None:
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if ctx.is_valid:
        return format(ctx.trace_id, "032x")
    return None

Этот trace-id стоит включать в тело ошибки — клиент сможет передать его в поддержку, а поддержка найдёт нужный запрос в системе трассировки.

Читать traceparent вручную

Если OpenTelemetry не подключён, заголовок читается как обычный:

@router.get("/customers/{customer_id}", operation_id="getCustomer")
async def get_customer(
    customer_id: str,
    traceparent: Annotated[str | None, Header()] = None,
) -> CustomerResponse:
    trace_id = None
    if traceparent:
        parts = traceparent.split("-")
        trace_id = parts[1] if len(parts) >= 2 else None

    return await customer_service.get(customer_id, trace_id=trace_id)

При наличии OpenTelemetry дублировать параметр в сигнатуре не нужно — trace-id берётся через API трейсера.

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

Префикс X- в кастомных заголовках. X-Request-Id выглядит привычно, но RFC 6648 признал этот префикс устаревшим. Используйте доменный префикс: Shop-Request-Id, Sber-Request-Id — один для всех сервисов.

Authorization без схемы. Писать Authorization: eyJhbGci... неправильно. Схема обязательна: Authorization: Bearer eyJhbGci....

Idempotency-Key на GET. GET-запросы идемпотентны по природе — их повтор ничего не меняет. Ключ нужен только для POST и PATCH.

Самописный заголовок для трассировки. Tracking-Id, X-Correlation-Id и подобные изобретения ломают совместимость с инструментами мониторинга. traceparent — стандарт W3C, его поддерживают все системы трассировки.

Location без полного пути. При 201 Created в заголовке Location должен быть полный относительный путь: /api/v1/orders/550e..., а не просто 550e....

Дополнительно: при первом чтении можно пропустить

Глубже: кэширование ответов API: Cache-Control, ETag и 304расширенное

Без явной инструкции решение о кэшировании примет каждый прокси и браузер по дороге, по своим правилам. Для API правило простое: у каждого GET есть осознанный Cache-Control.

Личные данные (заказы, профиль) отдают с Cache-Control: private, max-age=60 или no-store, если ответ не должен осесть даже в кэше браузера. Публичный справочник (/countries, /categories) можно отдать с public, max-age=3600, и его закэширует CDN. Ответы с ошибками и всё, что зависит от токена, кэшировать нельзя, и no-store там ставят явно.

ETag экономит трафик на повторных запросах. Сервер считает отпечаток ответа (хватает updated_at и версии строки, хешировать тело не обязательно), клиент присылает его в If-None-Match, и при совпадении сервер отвечает пустым 304 Not Modified:

etag = f'"{order.version}"'
if request.headers.get("If-None-Match") == etag:
    return Response(status_code=304, headers={"ETag": etag})
response.headers["ETag"] = etag

Тот же ETag используют для If-Match при PUT: клиент меняет только ту версию, которую видел, иначе 412 Precondition Failed. Если ответ зависит от Accept-Language, добавляют Vary: Accept-Language, иначе кэш отдаст русскому клиенту английский текст.

Глубже: CORS: что добавить в ответ, чтобы браузер разрешилрасширенное

Первый же вызов API со страницы на другом домене заканчивается blocked by CORS policy в консоли и запросом OPTIONS в логе сервера, который никто не отправлял. Механизм разобран в статье про HTTP; здесь то, что относится к контракту.

В FastAPI это CORSMiddleware из Starlette, и четыре его настройки определяют поведение. allow_origins перечисляют явно: со списком middleware отвечает Access-Control-Allow-Origin только знакомому origin, а чужому отдаёт ответ без этого заголовка, и браузер его не покажет. С allow_origins=["*"] и allow_credentials=True Starlette подставляет в заголовок любой пришедший origin, то есть cookie вашего пользователя сможет использовать любой сайт; так не делают. expose_headers обязателен для всего, что JavaScript должен прочитать сверх стандартного набора: Location после 201, RateLimit-*, свои заголовки с доменным префиксом, иначе response.headers.get(...) в браузере вернёт null. max_age кэширует ответ на предзапрос OPTIONS, по умолчанию 600 секунд.

Чего CORS не делает: не защищает API. Это ограничение браузера для чужих страниц, а curl и чужой сервер его не видят; авторизация остаётся на токенах. И предзапрос OPTIONS должен отвечать быстро и без проверки токена, иначе каждый вызов с фронтенда ждёт два запроса к базе.

Коротко

  • Заголовки объявляются в сигнатуре функции через Header() — FastAPI сам читает их из запроса и показывает в OpenAPI.
  • FastAPI конвертирует snake_case-параметры в kebab-case заголовки: if_none_match → If-None-Match.
  • Кастомные заголовки — с доменным префиксом (Shop-*, Sber-*); префикс X- устарел.
  • Idempotency-Key защищает от двойного создания при повторном POST — клиент генерирует ключ один раз на операцию.
  • traceparent (W3C) несёт единый trace-id через всю цепочку сервисов; OpenTelemetry подхватывает его автоматически.
  • Location при 201 Created добавляется вручную через response.headers["Location"].
  • У каждого GET осознанный Cache-Control (личное — private или no-store), ETag с ответом 304; CORS — список origin явно, expose_headers для Location и RateLimit-*, а * с credentials подставляет любой origin.

Что пощупать

Заголовок Idempotency-Key в сервисе заказов практикума remodov/marketplace-system-python: роутер требует его через Header при создании заказа и считает SHA-256 от тела; повтор с тем же ключом и телом отвечает 200 и прежним заказом, тот же ключ с другим телом - 409 IDEMPOTENCY_KEY_CONFLICT. Ключ занимается insert(...).on_conflict_do_nothing() в одной транзакции с заказом, и восемь одновременных запросов создают один заказ.

Код: orders.py, idempotency_repository.py, test_idempotency.py.

Сделаем сами

Ветка step-09-idempotency - проверка и занятие ключа вынуты, три теста красные.

Что почитать дальше