Когда клиент шлёт запрос, он передаёт не только тело — но и заголовки: кто он, что ожидает получить, есть ли у него токен. Сервер отвечает тем же: что вернул, можно ли кешировать, где найти созданный ресурс. Разберём, как 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 | можно ли кешировать ответ и как долго |
Location | URL только что созданного ресурса при ответе 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_match→If-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при выводе эндпоинта из использования.