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_* красные.
Что почитать дальше
- Ошибки RFC 9457 в Go — как
traceIdпопадает в тело ошибки. - Rate limiting в Go — заголовки
Retry-AfterиRateLimit-*. - JSON и формат ответов в Go — заголовок
Locationпри создании ресурса.