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

API: REST И КОНТРАКТЫ HTTP-заголовки

Обязательно

HTTP-заголовки в Go: контракт и трассировка

Go · OpenTelemetry

Стандартные и собственные метаданные, безопасный повтор через Idempotency-Key и сквозной traceparent.

1. Заголовки описывают контекст сообщения

1 / 5

Content-Type и Accept задают представления, Authorization подтверждает доступ, Cache-Control управляет кэшем.

  • Стандартные имена не заменяют собственными аналогами.
  • Значения валидируются как и тело.
  • Секреты не записываются в логи без маскирования.
POST /orders HTTP/1.1Content-Type: application/jsonAccept: application/jsonAuthorization: Bearer •••Idempotency-Key: K-42traceparent: 00-...

2. Собственный заголовок называет назначение

2 / 5

Придуманный X- префикс не делает имя стандартным. Лучше понятное имя и документированный формат.

  • Idempotency-Key и traceparent используют существующие соглашения.
  • Доменные метаданные не дублируют тело.
  • Размер и число заголовков ограничиваются инфраструктурой.
X-Dataнепонятное имянеясный форматIdempotency-Keyназначение очевидноконтракт документированМетаданные должны быть необходимы протоколу

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

3 / 5

Одинаковый ключ и запрос возвращают сохранённый результат. Другой payload с тем же ключом получает конфликт.

  • Ключ выдаёт клиент на бизнес-операцию.
  • Сервер хранит результат вместе с ключом.
  • Повтор после таймаута не создаёт новый платёж.
POST + K-42timeoutK-42stored resultRetry + K-42same resultНе создаём вторую операцию

4. traceparent продолжает распределённый след

4 / 5

W3C Trace Context передаёт trace ID и parent span ID. Middleware принимает контекст, создаёт серверный span и передаёт его дальше.

  • Один trace связывает все сервисы.
  • Новый span не заменяет общий trace ID.
  • otelhttp ставится до бизнес-обработчика.
ClientAPI spanBilling spanDB spantrace ID сохраняется, parent ID меняется

5. Порядок middleware определяет видимость

5 / 5

Request ID и tracing должны появиться до логирования и обработки ошибок. Авторизация не должна обрезать наблюдаемость.

  • Сначала контекст запроса и восстановление.
  • Затем трассировка и access log.
  • После них auth, rate limit и handler.
TraceLogAuthRate limitHandlerОшибка любого слоя получает общий trace

HTTP-заголовки — это пары «ключ: значение», которые идут вместе с каждым запросом и ответом. В них передают тип данных, токены авторизации, идентификаторы запросов и многое другое. Разберём, какие заголовки бывают, как их правильно использовать и чего избегать.

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

Для большинства задач уже есть стандартные заголовки, закреплённые в спецификациях HTTP. Их нужно использовать по назначению, а не изобретать свои аналоги.

Самые распространённые в REST API:

// Тип содержимого запроса или ответа
w.Header().Set("Content-Type", "application/json")

// Токен авторизации — читаем из входящего запроса
token := r.Header.Get("Authorization") // "Bearer <token>"

// Язык, который предпочитает клиент — для локализации ответов
lang := r.Header.Get("Accept-Language")

// Адрес нового ресурса — возвращаем при создании (статус 201)
w.Header().Set("Location", "/api/v1/orders/"+orderID)

Authorization лучше разбирать в middleware и класть результат в контекст запроса — тогда в хендлерах не нужно повторять одну и ту же проверку.

Кастомные заголовки

Иногда стандартных заголовков не хватает. Например, нужно передать внутренний идентификатор запроса или версию API клиента.

Раньше для таких случаев использовали префикс X-: X-Request-Id, X-Api-Version. В 2012 году RFC 6648 отменил эту практику, потому что X- заголовки со временем становились стандартными, и префикс только создавал путаницу. Сейчас кастомные заголовки называют с содержательным доменным префиксом: Shop-Request-Id, Shop-Api-Version.

Одно исключение ради совместимости: готовый middleware.RequestID из chi читает и пишет X-Request-Id, потому что этот заголовок понимают прокси и балансировщики. Пользоваться им можно; правило про префикс относится к заголовкам, которые вы придумываете сами.

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

const headerRequestID = "Shop-Request-Id"
const headerVersion   = "Shop-Api-Version"

func requestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := r.Header.Get(headerRequestID)
        if requestID == "" {
            requestID = uuid.New().String()
        }
        w.Header().Set(headerRequestID, requestID)
        ctx := context.WithValue(r.Context(), ctxRequestID{}, requestID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Middleware генерирует идентификатор, если клиент его не передал, и кладёт в заголовок ответа — чтобы можно было найти запрос в логах по его ID.

Idempotency-Key: защита от двойных операций

Представьте, что клиент отправил запрос «создать заказ», сеть оборвалась, и он не знает — заказ создан или нет. Если он повторит запрос, может создаться два одинаковых заказа.

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

Это важно для операций с побочными эффектами: создание заказа, проведение платежа, отправка уведомления.

type cachedResponse struct {
    status int
    body   any
}

func createOrder(w http.ResponseWriter, r *http.Request) {
    idempotencyKey := r.Header.Get("Idempotency-Key")
    if idempotencyKey == "" {
        httperr.Write(w, r, apperr.NewValidation("Idempotency-Key header required"))
        return
    }

    // Уже выполняли этот запрос — возвращаем сохранённый ответ
    if cached, ok := idempotencyStore.Get(idempotencyKey); ok {
        writeJSON(w, cached.status, cached.body)
        return
    }

    var req CreateOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        httperr.Write(w, r, apperr.NewValidation("invalid request body"))
        return
    }

    order, err := svc.CreateOrder(r.Context(), toCreateOrderCommand(req))
    if err != nil {
        httperr.Write(w, r, err)
        return
    }

    resp := toOrderResponse(order)
    idempotencyStore.Set(idempotencyKey, cachedResponse{status: http.StatusCreated, body: resp}, 24*time.Hour)

    w.Header().Set("Location", "/api/v1/orders/"+order.ID)
    writeJSON(w, http.StatusCreated, resp)
}

Хранилище ключей — Redis или in-memory с TTL (обычно 24 часа). Если клиент повторяет запрос с тем же ключом, но другим телом — это ошибка клиента, возвращаем 409 Conflict.

Распределённая трассировка: traceparent

В микросервисной архитектуре один пользовательский запрос проходит через несколько сервисов. Когда что-то идёт не так, нужно понять весь путь запроса — через какие сервисы он прошёл, где замедлился, где возникла ошибка.

Для этого используется стандарт W3C Trace Context. Каждый запрос получает уникальный traceId, который передаётся между сервисами через заголовок traceparent. Все сервисы записывают свои отрезки (spans) в систему трассировки — и можно увидеть полную картину.

В Go это берёт на себя OpenTelemetry middleware:

import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

func main() {
    r := chi.NewRouter()
    r.Use(otelhttp.NewMiddleware("order-service")) // читает traceparent из входящего запроса
    // ...
}

Middleware автоматически читает traceparent из входящего запроса и продолжает трассу. Если заголовка нет — создаёт новую.

Чтобы добавить traceId в тело ошибки (для отладки на стороне клиента):

import "go.opentelemetry.io/otel/trace"

func traceIDFromCtx(ctx context.Context) string {
    span := trace.SpanFromContext(ctx)
    if !span.SpanContext().IsValid() {
        return ""
    }
    return span.SpanContext().TraceID().String()
}

Передача трассировки в исходящих запросах

Чтобы трасса продолжилась в следующем сервисе, нужно добавить traceparent в исходящий запрос:

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
)

func callDownstream(ctx context.Context, url string) (*http.Response, error) {
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
    return http.DefaultClient.Do(req)
}

Порядок middleware

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

r := chi.NewRouter()

r.Use(
    otelhttp.NewMiddleware("order-service"), // traceparent
    requestIDMiddleware,                      // Shop-Request-Id
    RateLimitMiddleware(globalLimiter),       // Retry-After
    Recoverer,                                // `panic` → 500
)

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

X- в кастомных заголовках. Писать X-Request-Id или X-Trace-Id — устаревшая практика. Используйте содержательный префикс (Shop-Request-Id) или стандартный заголовок (traceparent).

Authorization без middleware. Разбирать токен прямо в хендлере и не проверять, что он там есть — частая ошибка. Лучше вынести это в middleware и положить результат в контекст.

Idempotency-Key без хранилища. Принять ключ, но не запомнить результат операции — значит дать ложную гарантию. Повторный запрос снова создаст заказ.

Собственный propagation вместо W3C. Иногда придумывают свой формат для передачи трассировочного ID. Это ломает совместимость с готовыми инструментами — Jaeger, Zipkin, Grafana Tempo все понимают traceparent.

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

Глубже: гонка двух одинаковых запросов и кэш ответоврасширенное

У примера с Idempotency-Key выше есть дыра, которую видно только под нагрузкой: два запроса с одним ключом приходят одновременно, оба не находят сохранённого ответа, оба создают заказ. Ключ надо занимать до операции, а не записывать после: INSERT INTO idempotency_keys (key, fingerprint) ... ON CONFLICT DO NOTHING в PostgreSQL или SET key NX в Redis; кто вставил — работает, кто получил конфликт — ждёт результат или отвечает 409, если операция ещё идёт. Рядом с ключом хранят отпечаток тела (хэш): тот же ключ с другим телом — ошибка клиента, 409 Conflict. И не кэшируют 5xx: повтор после сбоя должен выполниться заново. Хранилище в памяти процесса перестаёт работать со второй репликой сервиса — ключи держат в базе или Redis.

Вторая тема — кэш на стороне клиента. Мобильный клиент, который каждые тридцать секунд тянет список из двухсот позиций, меняющийся раз в час, гоняет тот же JSON впустую. Лечат парой заголовков: сервер отдаёт ETag — отпечаток тела, клиент присылает его обратно в If-None-Match, и при совпадении сервер отвечает 304 Not Modified без тела.

func writeCached(w http.ResponseWriter, r *http.Request, body []byte) {
    sum := sha256.Sum256(body)
    etag := `"` + hex.EncodeToString(sum[:8]) + `"`
    w.Header().Set("ETag", etag)
    w.Header().Set("Cache-Control", "private, max-age=60")
    if r.Header.Get("If-None-Match") == etag {
        w.WriteHeader(http.StatusNotModified)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.Write(body)
}

Cache-Control: max-age=60 разрешает клиенту минуту вообще не ходить на сервер, private запрещает общим кэшам хранить персональный ответ. Для больших списков отпечаток считают не по телу, а по updated_at и числу строк — иначе ради 304 придётся собрать весь ответ.

Коротко

  • Стандартные заголовки (Content-Type, Authorization, Location) используются строго по назначению.
  • Кастомные заголовки именуются с доменным префиксом без X-: Shop-Request-Id, не X-Request-Id.
  • Idempotency-Key защищает от двойных операций: сервер запоминает результат и отдаёт его при повторном запросе с тем же ключом.
  • traceparent — стандарт W3C для распределённой трассировки; otelhttp.NewMiddleware подключает его автоматически.
  • Для исходящих запросов нужно явно инжектировать traceparent через otel.GetTextMapPropagator().Inject.
  • Ключ идемпотентности занимают до операции (ON CONFLICT DO NOTHING, SET NX) и хранят с отпечатком тела; в памяти процесса он не живёт.
  • ETag и If-None-Match дают 304 без тела, Cache-Control: max-age — паузу между запросами; так лечат клиентов, которые опрашивают неизменный список.

Что пощупать

Ключ, который занимают до операции, в практикуме remodov/marketplace-system-go стоит на создании заказа: сервис заказов требует Idempotency-Key, считает хеш тела по каноническому JSON и в одной транзакции пишет заказ и ключ вставкой с ON CONFLICT DO NOTHING. Тот же ключ с тем же телом возвращает прежний заказ ответом 200, с другим телом даёт 409 IDEMPOTENCY_KEY_CONFLICT, а восемь одинаковых запросов, пришедших разом, создают один заказ: проигравшие гонку дожидаются первой транзакции, откатывают свой заказ и читают чужой.

Код: create_order.go, idempotency_repository.go, order_handler.go, idempotency_test.go.

Сделаем сами

Ветка step-09-idempotency - ключ доезжает до сценария, но его никто не проверяет и не занимает, три проверки TestIdempotency_* красные.

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