Опирается на правила: R-HDR-1..4, R-HDR-X1раздел Заголовки.

Важно знать

  • Стандартные заголовки используются по назначению: Content-Type, Authorization, Location.
  • Кастомные — с доменным префиксом, без X- (Shop-Request-Id, не X-Request-Id).
  • Idempotency-Key — для неидемпотентных POST с бизнес-эффектом.
  • traceparent — W3C Trace Context; интегрируется через OTel otelhttp.
  • traceId в теле ошибки — извлекается из OTel контекста в хендлере.
  • X- префикс в кастомных заголовках — запрещён (RFC 6648 устарел).

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

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

// Content-Type на запрос/ответ
w.Header().Set("Content-Type", "application/json")

// Authorization — читаем из запроса
token := r.Header.Get("Authorization") // "Bearer <token>"

// Accept-Language — для локализации (R-LOC-1)
lang := r.Header.Get("Accept-Language")

// Location — при создании ресурса (R-RSP-3)
w.Header().Set("Location", "/api/v1/orders/"+orderID)

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

R-HDR-2: доменный префикс, без X-.

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

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))
    })
}

Idempotency-Key

R-HDR-3: для POST с бизнес-эффектом (оплата, создание заказа).

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, http.StatusOK, cached)
        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, resp, 24*time.Hour)

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

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

W3C Trace Context — traceparent

R-HDR-4: OTel middleware автоматически читает traceparent из запроса и проставляет его в контекст.

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

func main() {
    r := chi.NewRouter()
    r.Use(otelhttp.NewMiddleware("order-service"))  // traceparent → context
    // ...
}

Извлечение traceId из контекста для подстановки в ProblemDetails:

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()
}

Использование в writeProblem:

func writeProblem(w http.ResponseWriter, status int, code, title, detail string, traceID string) {
    p := ProblemDetails{
        Type:    problemType(code),
        Title:   title,
        Status:  status,
        Detail:  detail,
        Code:    code,
        TraceID: traceID,
    }
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(p)
}

// в хендлере или httperr.Write:
httperr.Write(w, r, err)
// внутри: traceID := traceIDFromCtx(r.Context())

Прокидывание 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)

    // инжектируем traceparent + tracestate в исходящий запрос
    otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

    return http.DefaultClient.Do(req)
}

Сборка middleware-стека

r := chi.NewRouter()

r.Use(
    otelhttp.NewMiddleware("order-service"),   // traceparent (R-HDR-4)
    requestIDMiddleware,                        // Shop-Request-Id (R-HDR-2)
    RateLimitMiddleware(globalLimiter),         // Retry-After (R-RATE-1)
    Recoverer,                                  // паника → 500
)

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

АнтипаттернПравилоЧто взамен
w.Header().Set("X-Request-Id", ...)R-HDR-X1Shop-Request-Id (без X-)
w.Header().Set("X-Trace-Id", ...)R-HDR-X1OTel traceparent (W3C стандарт)
Idempotency-Key без проверки кешаR-HDR-3хранить результат и возвращать при повторе
Кастомный propagation вместо W3CR-HDR-4otelhttp.NewMiddleware
r.Header.Get("Authorization") без проверкиR-HDR-1middleware извлекает и кладёт в контекст

Куда дальше

  • go/errors.md — traceId в теле ошибки.
  • go/rate-limiting-files-deprecation.md — Retry-After, RateLimit-*, Sunset.
  • go/json-and-responses.md — Location при создании.
  • go/batch-async-localization.md — Accept-Language.