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.
Префикс выбирается один раз для всего проекта и фиксируется в соглашениях команды:
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, // паника → 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.
Коротко
- Стандартные заголовки (
Content-Type,Authorization,Location) используются строго по назначению. - Кастомные заголовки именуются с доменным префиксом без
X-:Shop-Request-Id, неX-Request-Id. Idempotency-Keyзащищает от двойных операций: сервер запоминает результат и отдаёт его при повторном запросе с тем же ключом.traceparent— стандарт W3C для распределённой трассировки;otelhttp.NewMiddlewareподключает его автоматически.- Для исходящих запросов нужно явно инжектировать
traceparentчерезotel.GetTextMapPropagator().Inject.
Что почитать дальше
- Ошибки RFC 9457 в Go — как
traceIdпопадает в тело ошибки. - Rate limiting в Go — заголовки
Retry-AfterиRateLimit-*. - JSON и формат ответов в Go — заголовок
Locationпри создании ресурса.