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

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

Нагляднее всего это видно на повторе запроса: один заголовок решает, появится дубль заказа или нет.

POST /api/v1/orders прошёл, но ответ до клиента не дошёл — клиент повторяет запрос что уходит от клиента что делает сервер итог POST /api/v1/ordersIdempotency-Key: 550e8400создал заказ A-1024ключ и ответ сохранены201 · заказ A-1024ответ не дошёл тот же POSTключа нетвидит обычный POSTсоздаёт второй заказ A-1025201 · заказ A-1025в базе 2 заказа тот же POSTтот же ключ 550e8400нашёл этот ключвозвращает первый ответ201 · заказ A-1024в базе 1 заказ тот же ключ, другое телотот же ключ, первый в работеключ привязан к операциирезультата ещё нет422 · дубля нет409 · повторить позже без ключа повтор оставил 2 заказа, с ключом — тот же один A-1024

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

Обязательно

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

HTTP давно стандартизировал самые нужные случаи. Их не надо изобретать — достаточно использовать правильно.

ЗаголовокГде ставитсяЧто означает
Content-Typeзапрос и ответформат тела (application/json)
Acceptзапроскакой формат ответа ждёт клиент
Authorizationзапростокен аутентификации
Locationответ 201 CreatedURL созданного ресурса
ETagответверсия ресурса (хеш или номер)
If-None-Matchзапрос«верни ответ только если ETag изменился»
If-Matchзапрос«измени ресурс только если ETag совпадает»
Cache-Controlответинструкции для кеширования

Типичный обмен выглядит так:

живой пример

GET /api/v1/orders/550e8400
Accept: application/json
Authorization: Bearer eyJhbGci...
If-None-Match: "33a64df5"

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "33a64df5"
Cache-Control: private, max-age=60
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Когда сервер создаёт ресурс, он возвращает 201 Created и сообщает клиенту, где его найти:

HTTP/1.1 201 Created
Location: /api/v1/orders/550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

Клиент не должен угадывать URL — он берёт его из Location и сразу может сделать GET без лишнего шага.

ETag: одна метка для двух разных задач

Три строки таблицы — ETag, If-None-Match и If-Match — стоят рядом, но решают разные задачи, и это сбивает с толку. Разберём.

ETag — это метка версии ресурса: короткая строка, которая меняется, как только меняется сам ресурс. Обычно это хеш тела или номер версии из базы. Сервер кладёт её в ответ, клиент запоминает.

Первая задача — не гонять одно и то же по сети. Клиент при следующем запросе присылает If-None-Match с запомненной меткой: «отдай тело, только если оно другое». Если ресурс не менялся, сервер отвечает 304 Not Modified с пустым телом, и клиент берёт свою копию:

живой пример

GET /api/v1/orders/550e8400
If-None-Match: "33a64df5"

HTTP/1.1 304 Not Modified
ETag: "33a64df5"
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Вторая задача — не затереть чужую правку. И вот это уже не про кеш вовсе. Представьте: двое открыли один заказ, первый поменял адрес доставки и сохранил, второй через минуту сохранил свою версию — поверх. Правка первого исчезла, и никто не заметил.

Заголовок If-Match эту гонку ловит: «применяй изменение, только если версия всё ещё та, которую я читал».

живой пример

PUT /api/v1/orders/550e8400
If-Match: "33a64df5"
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Дальше возможны три исхода:

  • версия совпала — изменение применяется, в ответе новый ETag;
  • версия другая, кто-то успел раньше — 412 Precondition Failed. Клиент перечитывает заказ, показывает пользователю свежие данные и предлагает повторить;
  • клиент вообще не прислал If-Match, а сервер требует его для изменяющих запросов — 428 Precondition Required, то есть «без проверки версии я такое не принимаю».

Это тот же приём, что оптимистическая блокировка в базе данных с колонкой version, только вынесенный на уровень HTTP. Метка одна, а работает и на чтение, и на запись — просто заголовки разные.

кеш GET + If-None-Match метка та же 304, тела нет правка PUT + If-Match метка та же 200 и новый ETag гонка PUT + If-Match метка чужая 412, перечитай

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

Собственные заголовки — с доменным префиксом

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

Для этого добавляют собственные заголовки. Раньше их писали с префиксом X-: X-Request-Id, X-Client-Version. В 2012 году RFC 6648 официально признал этот подход устаревшим. Причина такая: заголовок, придуманный «на время», со временем становится общеупотребимым, его хочется стандартизировать — и тут выясняется, что переименовать его уже нельзя, все клиенты знают старое имя. Так в вебе и застряли стандартные по сути заголовки с временной буквой в начале.

Сразу оговорка: сам RFC 6648 доменный префикс не предписывает — он говорит ровно обратное, «не надо ставить приставок вообще», и X-, и любых похожих. Замена X-Request-Id на Shop-Request-Id — это наше командное соглашение, а не требование стандарта. Держимся его по практической причине: в логе сразу видно, где стандартный заголовок HTTP, а где наш собственный, и два разных продукта в одной цепочке вызовов не столкнутся именами.

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

Shop-Request-Id: 550e8400-e29b-41d4-a716-446655440000
Shop-Client-Version: 2.1.0
Shop-Tenant-Id: acme

Префикс выбирается один раз и фиксируется в стандартах команды. Все сервисы — order-service, payment-service, billing-service — используют один и тот же префикс. Это позволяет сразу отличить системный заголовок от стандартного HTTP.

Частая ошибка — путать Shop-Request-Id и трассировку. Shop-Request-Id идентифицирует конкретный запрос от конкретного клиента (для дедупликации и логирования). Трассировка — это другое, о ней ниже.

Idempotency-Key: безопасный повторный запрос

Представьте: клиент отправляет POST для создания заказа, но сеть обрывается и ответ не приходит. Клиент не знает — заказ создался или нет. Если он повторит запрос, может появиться дубль.

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

живой пример

POST /api/v1/orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{ "items": [...] }
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

Каким кодом отвечать на такое несовпадение — вопрос, по которому единого мнения пока нет. Черновик стандарта IETF предлагает 422: запрос синтаксически понят, но выполнить его нельзя, потому что ключ уже занят под другую команду. На практике не реже отвечают 409 — и именно этот код называет наш разбор идемпотентности: конфликт ключа с другой командой трактуют как конфликт состояния. Стандарта, который бы это закрыл, ещё нет, поэтому выберите один код на весь продукт и напишите его в контракте — клиенту важнее предсказуемость, чем номер.

Отдельно стоит третий случай: повтор пришёл, пока первый запрос ещё выполняется, и результата попросту нет. Тут 409 уместен при любом выборе — «занято, приходи позже».

В Spring MVC заголовок читается как обычный параметр:

@PostMapping("/orders")
public ResponseEntity<OrderResponse> create(
    @RequestHeader("Idempotency-Key") String idempotencyKey,
    @RequestBody @Valid CreateOrderRequest request
) {
    // ...
}

Idempotency-Key нужен там, где повтор запроса создаёт лишнюю сущность или списывает деньги дважды, — прежде всего это POST. GET, PUT и DELETE идемпотентны по своей природе: повторный DELETE не удалит второй раз, повторный PUT запишет то же самое. PATCH посередине: его идемпотентность зависит от содержимого тела, и если оно описано операциями вроде «добавь в список», ключ пригодится и здесь.

С повторным DELETE есть тонкость, о которой стоит договориться заранее. Состояние системы он действительно не портит — удалять уже нечего. Но код ответа при этом почти всегда другой: первый запрос вернул 204, а второй не нашёл ресурс и вернул 404. Клиент, который повторяет запрос после обрыва связи, увидит ошибку на операции, которая на самом деле прошла, и покажет пользователю «не удалось удалить».

Выходов два, и оба законны. Либо отвечать 204 и на повтор — «ресурса нет, чего вы и хотели»; тогда 404 остаётся только для заведомо чужих идентификаторов. Либо оставить 404, но честно написать в контракте, что повторный DELETE так себя ведёт, — и тогда клиент считает 404 после DELETE успехом. Главное — выбрать и записать, а не оставлять это на удачу.

Где живёт ключ идемпотентности

Заголовок без хранилища — просто строка в запросе. Работает он только вместе с таблицей, и устроена она так:

CREATE TABLE idempotency_keys (
    idempotency_key varchar(128) PRIMARY KEY,
    client_id       varchar(64)  NOT NULL,
    request_hash    varchar(64)  NOT NULL,
    status          varchar(16)  NOT NULL,          -- IN_PROGRESS | DONE
    response_status int,
    response_body   jsonb,
    created_at      timestamptz  NOT NULL DEFAULT now()
);

Уникальный ключ — сам ключ идемпотентности, и это главное: вставка строки происходит в той же транзакции, что и операция. Тогда повтор нарушает уникальность и не создаёт второй заказ. Проверять «а нет ли такого ключа» отдельным запросом бессмысленно: между проверкой и вставкой пролезет параллельный повтор.

Что хранится. Отпечаток запроса (чтобы отличить повтор от переиспользования ключа с другими данными), статус, а после выполнения — код и тело ответа. Именно тело позволяет ответить на повтор тем же самым, а не «уже обработано».

Сколько живёт. От суток до трёх: повтор может прийти позже, чем кажется — клиент вернулся из офлайна, сообщение застряло в очереди, человек нажал кнопку через час. Для платежей срок берут больше.

Что делать с ростом таблицы. Удалять старые записи по расписанию (DELETE … WHERE created_at < now() - interval '3 days' порциями) — иначе таблица растёт линейно потоку операций и однажды становится самой большой в базе. Тела ответов при этом можно чистить раньше самих ключей: ключ нужен для защиты от повтора, тело — для удобства ответа.

Промежуточное состояние. Запрос пришёл, операция идёт, повтор прилетел до её завершения. Правильный ответ — 409 (или 425) с пояснением «операция выполняется, повторите позже», а не выполнение второй раз и не ожидание внутри запроса.

Кто выдаёт ключ и каким он должен быть

Один ключ — одна бизнес-операция. Пользователь нажал «оплатить» — сгенерировали ключ и держим его, пока операция не завершится. Все повторы этой попытки (после таймаута, после обрыва, после перезапуска приложения) идут с тем же ключом. Новый ключ означает новую операцию — то есть второе списание.

Генерирует клиент, а не сервер. Смысл ключа в том, чтобы сервер узнал повтор; если бы ключ выдавал сервер, клиент не смог бы повторить запрос, ответ на который потерялся.

Уникальность — в пределах клиента. Сервер хранит пару «клиент плюс ключ»: тогда случайное совпадение ключей у двух клиентов не превратится в чужой ответ. Формат — случайный идентификатор (UUID) или устойчивый отпечаток операции; главное, чтобы он не угадывался и не повторялся.

Не брать ключ из тела запроса. Идентификатор заказа, номер документа, отпечаток содержимого — плохие ключи: одно и то же тело может означать две законные операции (два одинаковых платежа по 100 рублей — это норма), а разное тело с одним ключом надо ловить как ошибку, а не как повтор.

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

Кэширование ответов API

Три заголовка, которыми управляют кэшем, и они важнее, чем кажется: без них прокси и браузер кэшируют по своим догадкам.

Cache-Control — главный. Для ответов с персональными данными обязателен private (кэшировать может только браузер пользователя, но не прокси), а для того, что вообще нельзя хранить, — no-store. Для публичных справочников — public, max-age=3600. Молчание опаснее всего: общий прокси может отдать чужой ответ.

Cache-Control: private, max-age=0, must-revalidate   # личные данные
Cache-Control: public, max-age=86400                 # справочник валют
Cache-Control: no-store                              # одноразовый токен

ETag и 304. Сервер отдаёт отпечаток версии ресурса, клиент при следующем запросе присылает его в If-None-Match, и если ресурс не изменился, сервер отвечает 304 Not Modified без тела. Экономия — весь объём ответа; стоимость — вычисление отпечатка (но его можно хранить вместе с ресурсом, а не считать каждый раз).

Last-Modified и If-Modified-Since — то же самое, но по времени. Проще в реализации, грубее (секундная точность) и не годится, когда ресурс меняется чаще раза в секунду.

И условные изменения — обратная сторона того же механизма: клиент присылает If-Match с отпечатком, который он читал, а сервер отвечает 412, если ресурс с тех пор изменился. Это защита от слепой перезаписи: без неё две правки одной сущности заканчиваются тем, что вторая молча затирает первую.

Согласование, размеры и пределы

Accept-Language — язык ответа. Сервер обязан отдать Content-Language с тем, что реально выбрал; если поддерживаемого языка нет, возвращает свой основной, а не ошибку.

Content-Length против передачи частями. Длина известна заранее — отдают её; генерируется на ходу (выгрузка, поток) — передают частями. Практическое следствие: при передаче частями обрыв внешне похож на конец, поэтому полноту проверяют явно.

Content-Encoding — чем сжато тело. Включается на прокси; ответ, зависящий от Accept-Encoding, обязан иметь Vary, иначе кэш отдаст сжатое непонимающему клиенту.

Предел на размер заголовков. У сервера приложений он жёсткий — у Tomcat по умолчанию 8 КБ на все заголовки запроса, и превышение даёт 431 или обрыв соединения. В это упираются, когда в заголовках носят большие токены или длинные списки — типичный симптом «работает с коротким токеном, падает с длинным». Лечится не только увеличением предела: большие данные не место в заголовках.

Безопасность заголовков

Три правила, нарушение которых обходится дорого.

Токены не логируют и не передают в трассировку. Заголовок с авторизацией — это учётные данные; попав в журнал, он остаётся там, пока журнал жив, и виден всем, кто имеет доступ к журналам. В настройке журналирования запросов (Logbook, фильтры Spring) заголовки с авторизацией и куки маскируют явно, а не «по умолчанию».

Свои заголовки нельзя принимать снаружи, если по ним что-то решается. Если X-Tenant-Id определяет, чьи данные вернуть, то приходящий извне заголовок — это готовая подмена арендатора: любой клиент назовёт себя любым. Такие значения берут только из проверенного источника (подписанный токен), а одноимённый заголовок из внешнего запроса вырезают на входе в периметр.

Заголовки, которым доверяют, доверяются только за своим прокси. X-Forwarded-For и X-Forwarded-Proto определяют адрес клиента и схему; клиент, обратившийся напрямую, подставит их сам. Поэтому либо вход только через свой прокси, либо явный список доверенных источников.

Когда трассировка рвётся

«Spring Boot делает автоматически» верно для обычного пути запроса. Рвётся цепочка там, где меняется поток исполнения.

Асинхронный вызов. Метод с @Async выполняется в другом потоке, и контекст трассировки в него сам по себе не переезжает: дальше запись в журнале уже без идентификатора, и связать её с запросом нельзя. Лечится обёрткой исполнителя, которая переносит контекст (в Spring это ContextPropagatingTaskDecorator или соответствующая настройка наблюдаемости).

Свой пул потоков. То же самое, только руками: задача, отправленная в ExecutorService, теряет контекст. Оборачивают либо исполнитель, либо саму задачу.

Реактивные цепочки и потоки событий. Контекст живёт не в потоке, а в цепочке, поэтому переход между ними требует явного переноса.

Очередь сообщений. Контекст передают в сообщении (заголовком), а получатель восстанавливает его перед обработкой — иначе трассировка обрывается на границе брокера.

Признак, по которому это находят: в журнале есть записи с идентификатором трассировки и записи без него, причём вторые всегда из фоновых задач. Проверять стоит сразу, а не после первого инцидента.

traceparent — сквозная трассировка между сервисами

Когда пользователь делает запрос, он проходит через несколько сервисов: API-шлюз → order-service → payment-service → notification-service. Если где-то возникает ошибка или задержка, нужно понять, на каком именно шаге.

Раньше каждая команда изобретала свой заголовок — X-Trace-Id, X-Request-Id, Tracking-Id. Системы не понимали друг друга. W3C стандартизировал формат: traceparent.

traceparent: 00 - 1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c - 7a8b9c0d1e2f3a4b - 01 версия trace-id, 32 знака span-id, 16 знаков флаги

Заголовок один на всю цепочку вызовов: по trace-id собираются все участки одного запроса, а span-id говорит, чьим потомком будет следующий вызов. Сервис обязан передать его дальше — иначе цепочка обрывается на нём.

Что тут что:

  • trace-id — уникальный идентификатор всей цепочки вызовов от начала до конца. Один и тот же trace-id проходит через все сервисы.
  • span-id (в спецификации называется parent-id) — идентификатор текущего шага (span). Каждый сервис создаёт свой span-id.
  • флаги — 01 означает «запрос выбран для записи» (sampled).

Как сервис обрабатывает входящий запрос:

  • Клиент прислал traceparent → берём его trace-id, создаём новый span-id для своего шага и передаём дальше.
  • Клиент не прислал → генерируем trace-id с нуля, создаём span-id и передаём дальше.

В Spring Boot всё это делается двумя зависимостями. Первая — мост Micrometer к OpenTelemetry, он читает входящий traceparent, заводит span на каждый запрос и прокидывает заголовок в исходящие вызовы. Вторая отправляет собранные данные в коллектор (Jaeger, Tempo, что у вас стоит):

dependencies {
    implementation("io.micrometer:micrometer-tracing-bridge-otel")
    implementation("io.opentelemetry:opentelemetry-exporter-otlp")
}

Версии тут не указаны намеренно: обе библиотеки перечислены в наборе версий Spring Boot, и нужные номера подставятся сами.

Дальше — настройки:

management:
  tracing:
    sampling:
      probability: 1.0        # по умолчанию 0.1 — записывается каждый десятый запрос
  otlp:
    tracing:
      endpoint: http://collector:4318/v1/traces

И про логи. traceId и spanId не появляются в строке лога сами по себе — их надо взять из MDC и вписать в шаблон. Хорошая новость: Spring Boot это уже сделал. В стандартном шаблоне логирования зашита вставка traceId(32),spanId(16) из MDC, а мост Micrometer как раз кладёт туда оба значения. Поэтому со стартовой настройкой строка лога сразу выглядит так:

2026-05-26T10:30:00.123Z  INFO [order-service,1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c,7a8b9c0d1e2f3a4b] OrderService : заказ создан

Если своего шаблона логирования вы не писали — ничего дополнительно делать не нужно. Если писали — добавьте в него %mdc{traceId} и %mdc{spanId}, иначе идентификаторы соберутся, но в лог не попадут.

Один важный момент: trace-id из traceparent используется как поле traceId в теле ошибки (формат RFC 9457). Это позволяет клиенту получить ошибку и сразу найти полный путь запроса в системе трассировки по одному идентификатору.

Типичные ошибки

X- в собственных заголовках. X-Request-Id, X-Client-Version — устаревший стиль. Используйте доменный префикс: Shop-Request-Id, Shop-Client-Version.

Authorization без Bearer. JWT-токены передаются как Authorization: Bearer eyJhbGci.... Слово Bearer обязательно — это часть стандарта OAuth 2.0.

Idempotency-Key на GET. Не нужен: GET не создаёт побочных эффектов, повторный запрос всегда безопасен.

Самописный заголовок трассировки вместо traceparent. Свой X-Trace-Id не понимают системы мониторинга и другие сервисы. traceparent — межотраслевой стандарт.

trace-id короче 32 символов. Спецификация W3C требует ровно 32 hex-символа. 16 символов — другой формат, несовместимый.

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

Глубже: кэширование ответов API: Cache-Control, private и no-storeрасширенное

В примере выше стоит Cache-Control: private, max-age=60, и это не украшение: без явной инструкции решение о кэшировании примет каждый прокси и браузер по дороге, по своим правилам. Для API правило простое: у каждого GET есть осознанный Cache-Control, и чаще всего это запрет.

no-store запрещает хранить ответ где бы то ни было, и это умолчание для всего, что относится к конкретному пользователю или содержит персональные данные: профиль, заказы, баланс. Ответ с Authorization общие кэши и так обычно не сохраняют, но полагаться на «обычно» нельзя, а ответ по куке они сохранят с радостью, и следующий пользователь получит чужие заказы из кэша CDN.

private, max-age=60 разрешает хранить только браузеру этого пользователя и только минуту: список его заказов при переходах назад и вперёд не перезапрашивается, а общий кэш его не видит. public, max-age=300 открывает общий кэш и CDN, и это ответ для каталога, справочников, публичных карточек: одинаковый для всех, меняется редко, читается миллионами. s-maxage задаёт отдельный срок для общего кэша, stale-while-revalidate разрешает отдать устаревшее, пока в фоне обновляется.

ETag работает поверх всего этого: когда max-age истёк, клиент спрашивает с If-None-Match и получает 304 без тела. В Spring за это отвечает ShallowEtagHeaderFilter: он считает хэш готового тела и сравнивает с заголовком, экономя трафик, но не работу сервера, ответ всё равно вычисляется. Настоящая экономия появляется, когда ETag берётся из версии строки в базе до того, как собран ответ.

Ловушки. no-cache не запрещает хранить, а требует перепроверять, и часто ставится по ошибке вместо no-store. Ответ, который зависит от заголовка запроса (Accept-Language, Authorization), обязан нести Vary с его именем, иначе общий кэш смешает варианты. И POST не кэшируется никогда, поэтому чтение, которое из-за длины фильтров уехало в POST /search, теряет кэш, о чём говорит статья про параметры запроса.

Глубже: CORS: что добавить в ответ, чтобы браузер разрешилрасширенное

Первый же вызов API со страницы на другом домене заканчивается blocked by CORS policy в консоли и запросом OPTIONS в логе сервера, который никто не отправлял. Механизм разобран в статье про HTTP; здесь то, что относится к контракту.

Сервер разрешает конкретные источники, методы и заголовки, и это настройка, а не код в каждом контроллере. В Spring это CorsConfiguration через WebMvcConfigurer.addCorsMappings или, если стоит Spring Security, http.cors(...) с источником настроек, иначе Security ответит на OPTIONS отказом раньше MVC. Три правила для API: источники перечислены явно, без звёздочки, если отправляются куки или Authorization; заголовки ответа, которые фронтенд должен прочитать (Location, ETag, X-Request-Id), перечислены в Access-Control-Expose-Headers, иначе браузер их скрывает; Access-Control-Max-Age выставлен, чтобы предзапрос не повторялся перед каждым вызовом.

Предзапрос приходит без Authorization и обязан отвечать 2xx без аутентификации, и его нельзя считать в лимитах и метриках как вызов API. И CORS не защищает сам API: curl, мобильное приложение и чужой сервер его не спрашивают, так что правила доступа остаются на токенах, а CORS лишь говорит браузерам, каким страницам можно.

Коротко

  • Заголовки несут метаданные запроса и ответа: Content-Type, Accept, Authorization, Location, ETag, Cache-Control. Токены в журнал и в трассировку не попадают, свои заголовки снаружи не принимают, если по ним что-то решается, а предел размера заголовков у сервера жёсткий (у Tomcat 8 КБ).
  • Location в 201 Created сообщает клиенту URL созданного ресурса — без него клиент не узнает адрес.
  • ETag + If-None-Match — про кеш (304); ETag + If-Match — про оптимистическую блокировку (412 при устаревшей версии, 428 если проверку вообще не прислали).
  • X- в собственных заголовках устарел по RFC 6648; доменный префикс (Shop-Request-Id) — уже наше командное соглашение, стандарт его не требует.
  • Повтор запроса без ключа сервер видит как новый запрос, поэтому обрыв связи оборачивается вторым заказом, а не ошибкой. Idempotency-Key это чинит: ключ генерирует клиент один раз на бизнес-операцию, сервер хранит его в таблице с уникальным индексом вместе с отпечатком запроса и готовым ответом (сутки-трое, с уборкой по расписанию) и вставляет в той же транзакции, что и операцию. Тот же ключ с другим телом — 422 или 409; выберите одно и запишите в контракт.
  • Повторный DELETE состояние не портит, но обычно отвечает 404 вместо 204 — договоритесь, что это значит для клиента.
  • traceparent (W3C Trace Context) несёт trace-id через все сервисы цепочки; в Spring Boot это включает micrometer-tracing-bridge-otel. Цепочка рвётся на смене потока — @Async, свой пул, реактивные цепочки, очередь: контекст переносят декоратором исполнителя или заголовком сообщения.
  • trace-id из traceparent используется как traceId в теле ошибок — по нему находят полный путь запроса в трассировщике.
  • У каждого GET осознанный Cache-Control: no-store для личного, private, max-age для браузера пользователя, public, max-age для каталога; Vary при зависимости от заголовков; no-cache не то же, что no-store.
  • CORS настраивают одним местом (и через Spring Security, если он есть): источники явно, Expose-Headers для Location и ETag, Max-Age для предзапроса; предзапрос идёт без токена и не считается вызовом.

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