Опирается на правила: R-HDR-1, R-HDR-2, R-HDR-3, R-HDR-4, R-HDR-X1 из REST API Style Guide → раздел Заголовки и трассировка.

Важно знать

  • Стандартные HTTP-заголовки объявляются через Header(...) в сигнатуре функции — FastAPI извлекает их автоматически и включает в OpenAPI.
  • Кастомные заголовки — с доменным префиксом единым для всех сервисов проекта; префикс X- запрещён по RFC 6648 (R-HDR-X1).
  • Idempotency-Key обязателен для POST-операций, безопасных при повторной отправке (деньги, создание заказов).
  • traceparent (W3C Trace Context) прокидывается через весь граф сервисов; trace-id из него попадает в тело ошибки RFC 9457.
  • FastAPI автоматически конвертирует snake_case-аргументы в HTTP-заголовки kebab-case (idempotency_keyIdempotency-Key).
  • OpenTelemetry (opentelemetry-instrumentation-fastapi) извлекает traceparent и прокидывает в исходящие запросы без ручного кода.
  • Location заголовок при 201 Created возвращается явно через Response — FastAPI не добавляет его автоматически.

Заголовки в FastAPI — часть публичного контракта так же, как URL и тела. Они объявляются в сигнатуре endpoint-функции и автоматически попадают в сгенерированную OpenAPI-спецификацию. Именно это отличает code-first от ручного описания: заголовок объявлен один раз — в коде, не в YAML.

Стандартные HTTP-заголовки

R-HDR-1: используются по назначению.

ЗаголовокНазначениеПример значения
Content-Typeтип тела запроса / ответаapplication/json
Acceptожидаемый тип ответаapplication/json
AuthorizationаутентификацияBearer eyJhbGci...
LocationURL созданного ресурса при 201 Created/api/v1/orders/550e...
ETagверсия ресурса для кеширования"33a64df5"
If-None-Matchусловный GET"33a64df5"
If-Matchoptimistic concurrency для PUT / PATCH"33a64df5"
Cache-Controlдирективы кеширования ответаprivate, max-age=60

FastAPI читает Accept и Authorization через стандартный Header(...). Content-Type FastAPI разбирает сам — объявлять его через параметр не нужно.

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

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


@router.get(
    "/orders/{order_id}",
    operation_id="getOrder",
    summary="Получить заказ по ID",
)
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"},
    )

Location при 201 Created

FastAPI не добавляет Location автоматически — нужно передать явно через параметр headers конструктора Response или через Response-зависимость:

from fastapi import APIRouter, Response, status
from fastapi.responses import JSONResponse

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


@router.post(
    "/orders",
    status_code=status.HTTP_201_CREATED,
    operation_id="createOrder",
    summary="Создать заказ",
)
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 FastAPI дополняет заголовки не заменяя тело — Pydantic-модель сериализуется штатно, а Location добавляется поверх.

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

R-HDR-2: доменный префикс выбирается один раз и применяется во всех сервисах проекта.

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

В FastAPI параметр объявляется в snake_case — фреймворк автоматически маппит на kebab-case HTTP-заголовок:

from fastapi import APIRouter, Header
from typing import Annotated
from uuid import UUID

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


@router.get(
    "/products/{product_id}",
    operation_id="getProduct",
    summary="Получить продукт",
)
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)

FastAPI транслирует sber_request_idSber-Request-Id, sber_client_versionSber-Client-Version. Никакого ручного alias не нужно.

Sber-Request-Idtraceparent:

  • Sber-Request-Id — идентификатор одного запроса от клиента (дедупликация, логирование на уровне сервиса).
  • traceparent — идентификатор всей цепочки вызовов через несколько сервисов.

Idempotency-Key

R-HDR-3: безопасный retry для неидемпотентных POST-запросов.

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

{ "customerId": "cust-123", "items": [...] }

Контракт:

  • Клиент генерирует ключ один раз на бизнес-операцию (не на HTTP-запрос).
  • Повторный POST с тем же ключом → backend возвращает первый результат, заказ не создаётся дважды.
  • Тот же ключ с другим payload → 409 Conflict.

В FastAPI — обязательный заголовок через Header(...):

from fastapi import APIRouter, Header, Response, status
from typing import Annotated
from uuid import UUID

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


@router.post(
    "/orders",
    status_code=status.HTTP_201_CREATED,
    operation_id="createOrder",
    summary="Создать заказ",
)
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

FastAPI маппит idempotency_keyIdempotency-Key автоматически. Тип UUID обеспечивает валидацию формата на уровне Pydantic до вызова handler.

Если заголовок обязателен (нет дефолта) — FastAPI вернёт 422 при его отсутствии. После переопределения RequestValidationError-handler (см. python/errors) ответ будет 400 VALIDATION_ERROR с violations.

traceparent — W3C Trace Context

R-HDR-4: distributed tracing по стандарту W3C Trace Context.

traceparent: {version}-{trace-id}-{parent-id}-{trace-flags}

00-1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c-7a8b9c0d1e2f3a4b-01
│  │                                │                │
│  trace-id (32 hex)                 parent-id (16)  flags
version
  • version — формат, сейчас всегда 00.
  • trace-id — 32 hex, уникальный ID всей цепочки вызовов.
  • parent-id — 16 hex, ID текущего span.
  • trace-flags — 2 hex, 01 = sampled.

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

Предпочтительный путь — opentelemetry-instrumentation-fastapi: middleware перехватывает traceparent, создаёт span, прокидывает контекст в исходящие запросы через httpx / aiohttp.

# main.py
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, создаёт новый parent-id для своего span.
  • Нет traceparent → генерируется новый trace-id на входе.
  • trace-id доступен в контексте через trace.get_current_span().get_span_context().trace_id.

trace-id в теле ошибки RFC 9457

trace-id из traceparent должен попасть в поле traceId тела ошибки — это позволяет клиенту передать его в поддержку:

from fastapi import Request
from fastapi.responses import Response
from fastapi.exceptions import RequestValidationError
from opentelemetry import trace
import json


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


async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError,
) -> Response:
    violations = [
        {"field": ".".join(str(loc) for loc in e["loc"][1:]), "message": e["msg"]}
        for e in exc.errors()
    ]
    body = {
        "type": "urn:problem:orders:validation-error",
        "title": "Validation Error",
        "status": 400,
        "code": "VALIDATION_ERROR",
        "traceId": get_trace_id(),
        "violations": violations,
    }
    return Response(
        content=json.dumps({k: v for k, v in body.items() if v is not None}),
        status_code=400,
        media_type="application/problem+json",
    )

Ручное чтение traceparent (когда нет OTel)

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

from fastapi import APIRouter, Header
from typing import Annotated

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


@router.get(
    "/customers/{customer_id}",
    operation_id="getCustomer",
    summary="Получить клиента",
)
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)

Ручное чтение — только когда нет OTel. При наличии инструментации — trace-id доступен через API трейсера, дублировать параметр в сигнатуре не нужно.

tracestate

Опциональный заголовок для vendor-специфичных данных. В большинстве сервисов не нужен — OTel заполняет его если используется vendor-specific tracer.

traceparent: 00-1f2a8b6c...-7a8b9c0d...-01
tracestate: vendor1=value1,vendor2=value2

Что запрещено

АнтипаттернПравилоЧто взамен
X-Request-Id: ... — префикс X-R-HDR-X1Sber-Request-Id (доменный префикс)
Кастомный заголовок без префикса (Request-Id)R-HDR-2единый Shop-* / Sber-* для всех сервисов
Authorization: eyJhbGci... без схемы BearerR-HDR-1Authorization: Bearer eyJhbGci...
Idempotency-Key на GET / DELETER-HDR-3только для POST / PATCH
Самописный Tracking-Id вместо traceparentR-HDR-4W3C traceparent + OpenTelemetry
trace-id 16 hex вместо 32R-HDR-432 hex строчными (format(id, "032x"))
Бизнес-данные в заголовке вместо телаR-HDR-1тело запроса / path / query
Location без /api/v1/ префикса в значенииR-HDR-1полный относительный путь /api/v1/orders/{id}

Куда дальше

  • python/errors.md — traceId в теле ошибки RFC 9457, переопределение RequestValidationError-handler.
  • python/json-and-responses.md — Location для 201 Created, exclude_none в ответах.
  • python/versioning.md — Deprecation и Sunset заголовки при выводе эндпоинта из эксплуатации.
  • python/rate-limiting-files-deprecation.md — Retry-After и RateLimit-* при 429.
  • Observability → tracing — OpenTelemetry, traceparent propagation через httpx.
  • REST API — нормативный индекс — формулировки R-HDR-*.