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

Когда клиент шлёт запрос, он передаёт не только тело — но и заголовки: кто он, что ожидает получить, есть ли у него токен. Сервер отвечает тем же: что вернул, можно ли кешировать, где найти созданный ресурс. Разберём, как 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 hex)        parent-id (16) флаги
  • 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....

Коротко

  • Заголовки объявляются в сигнатуре функции через Header() — FastAPI сам читает их из запроса и показывает в OpenAPI.
  • FastAPI конвертирует snake_case-параметры в kebab-case заголовки: if_none_matchIf-None-Match.
  • Кастомные заголовки — с доменным префиксом (Shop-*, Sber-*); префикс X- устарел.
  • Idempotency-Key защищает от двойного создания при повторном POST — клиент генерирует ключ один раз на операцию.
  • traceparent (W3C) несёт единый trace-id через всю цепочку сервисов; OpenTelemetry подхватывает его автоматически.
  • Location при 201 Created добавляется вручную через response.headers["Location"].

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

  • Ошибки и коды ответов в FastAPI — traceId в теле ошибки, переопределение обработчика валидации.
  • JSON и формат ответов в FastAPI — camelCase, ISO 8601, exclude_none.
  • Версионирование REST API в FastAPI — заголовки Deprecation и Sunset при выводе эндпоинта из использования.