Модуль 4 · Ошибки и устойчивость

Справочник «Стандартизация HTTP API» · модуль 4 · Ошибки и устойчивость

Глава 4.3

4.3

Заголовки
контракта

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

Категория
Устойчивость
Уровень
Средний
Связанные главы
4.2 Диагностика ответа · 2.2 Методы · 7.4 Стандарты компаний
Зачем это знать
Чтобы API переживал гонки, потерянные ответы и наплыв запросов

§1Три неприятности и три заголовка

Что случаетсяКак выглядитЧем лечится
Потерянное изменениеДвое открыли обращение, оба сохранили, правка первого исчезлаETag + If-Match → 412
Потерянный ответКлиент не дождался ответа, повторил POST, получил два обращенияIdempotency-Key
Наплыв запросовКлиент в цикле, сервис отвечает всем медленноX-RateLimit-* + Retry-After → 429
Лишний трафикКлиент раз в секунду скачивает то же самоеETag + If-None-Match → 304
Неразбираемая жалоба«Вчера что-то не сохранилось» и никаких подробностейX-Request-Id

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

§2ETag: версия представления

ETag — короткая строка, которая меняется при каждом изменении ресурса. Клиенту не важно, как она вычисляется; важно, что одинаковый ETag означает одинаковое содержимое. Учебный сервис считает его от полей обращения.

настоящий ответ на GET
HTTP/1.1 200
etag: "4fec2a976840743a"
x-request-id: support-case-1842
content-type: application/json

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

If-None-Match

«Пришли, только если изменилось.» Экономит трафик при чтении. Ответ — 304 без тела.

If-Match

«Изменяй, только если не изменилось с моего прошлого чтения.» Защищает от потерянных правок. Ответ при расхождении — 412.

Zalando RESTful API Guidelines, правило 182 «MAY consider to support Etag together with If-Match/If-None-Match header»правила компании

«MAY consider to support ETag together with If-Match / If-None-Match header to allow the server to react on stricter demands that expose conflicts and prevent lost updates.»

Перевод. MAY: можно предусмотреть поддержку ETag вместе с заголовками If-Match и If-None-Match, чтобы сервер мог отвечать на более строгие требования, обнаруживающие конфликты и предотвращающие потерянные изменения.

Документация GitHub REST API о заголовках ETag и If-None-Match
Тот же механизм у GitHub. В своей документации GitHub прямо просит клиентов присылать If-None-Match: ответ 304 не расходует квоту запросов. Приём не учебный — на нём держится работа крупных API.

§3If-None-Match: не присылать то же самое

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

  1. Первое чтение. Клиент получает 200, тело и ETag: "4fec2a976840743a". Значение он запоминает.
  2. Следующее чтение. Клиент шлёт If-None-Match: "4fec2a976840743a".
  3. Ничего не изменилось. Сервис отвечает 304 Not Modified — без тела. Клиент рисует то, что уже держит в памяти.
  4. Что-то изменилось. Сервис отвечает 200, новым телом и новым ETag.
настоящий ответ 304
HTTP/1.1 304
etag: "4fec2a976840743a"
x-request-id: d1992782-8a1d-444f-b2ba-1cf8891806b6

Тела в таком ответе нет: по стандарту у 304 его быть не должно. Выигрыш тем заметнее, чем больше ресурс и чаще опрос; у GitHub такой ответ вдобавок не списывается из лимита запросов.

§4If-Match: потерянное изменение

Потерянное изменение выглядит так. Двое открыли одно обращение. Первый дописал описание и сохранил. Второй, ничего не зная, сохранил свою версию — и правка первого исчезла бесследно, без единой ошибки.

сценарий без If-Match
Аня  GET  /tickets/42   -> status: open, description: "…"
Борис GET  /tickets/42   -> то же самое
Аня  PATCH /tickets/42  -> description дописан        # 200
Борис PATCH /tickets/42  -> status: closed             # 200, правка Ани затёрта

С условным запросом второе сохранение не проходит: сервис видит, что версия изменилась.

сценарий с If-Match
Борис PATCH /tickets/42
      If-Match: "4fec2a976840743a"    # версия, прочитанная до правки Ани
      -> 412 Precondition Failed
настоящий ответ учебного сервиса
HTTP/1.1 412
x-request-id: 8952af24-1c19-4801-8f28-181a89f6265f
content-type: application/problem+json

{
  "type": "https://…/problems/http-412",
  "title": "Precondition failed",
  "status": 412,
  "detail": "Ticket was changed after the version in If-Match was read",
  "instance": "/api/v1/tickets/a8d04cb9-87ea-49b9-8ef6-ba10bbbad82b"
}

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

Страница вида проблемы http-412 с описанием причины и порядка действий клиента
Страница вида проблемы. Адрес из поля type ведёт сюда: когда возникает 412, что это значит и что делать. Это и есть требование RFC 9457 из главы 4.1, выполненное на практике.

§5Idempotency-Key: потерянный ответ

Метод POST не идемпотентен: два одинаковых запроса создают два ресурса. Обычно это правильно, но не тогда, когда второй запрос — повтор первого, ответ на который потерялся в сети.

Без ключа
  • клиент не дождался ответа
  • повторил POST
  • создано два обращения
  • разбирать придётся вручную
С ключом
  • клиент шлёт Idempotency-Key
  • повторяет тот же запрос с тем же ключом
  • сервис возвращает тот же результат
  • обращение одно

Проверка на учебном сервисе: два запроса с одинаковым ключом дали одно и то же обращение — id совпал, общее число выросло на единицу, а не на две.

первый запрос с ключом
HTTP/1.1 201
etag: "72d232d249fabd6e"
location: /api/v1/tickets/6dfeb4e3-497b-4d62-8270-40dace132b9f
x-ratelimit-limit: 60
x-ratelimit-remaining: 57
повтор с тем же ключом
тот же id: True        # 6dfeb4e3-…-40dace132b9f
всего обращений: 4      # не 5

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

тот же ключ, другое тело
HTTP/1.1 422
{
  "title": "Validation error",
  "status": 422,
  "detail": "Idempotency-Key was already used with a different request body",
  "instance": "/api/v1/tickets"
}
Статус механизма

Единого утверждённого RFC на Idempotency-Key нет: черновик IETF draft-ietf-httpapi-idempotency-key-header-07 истёк. Заголовок при этом давно живёт на практике: его использует Stripe, а свод Zalando описывает его правилом уровня MAY — «можно предусмотреть», а не «обязательно». Здесь порядок сложился из практики крупных API раньше, чем из документа. Раз общего стандарта нет, единственным источником правил остаётся ваше собственное описание, поэтому такое решение в API Style Guide расписывают подробно.

Zalando RESTful API Guidelines, правило 230 «MAY consider to support Idempotency-Key header»правила компании

«The unique request key is stored temporarily, e.g. for 24 hours, together with the response and the request hash (optionally) of the first request in a key cache, regardless of whether it succeeded or failed.»

Перевод. Уникальный ключ запроса хранится некоторое время, например 24 часа, вместе с ответом и (необязательно) хешом первого запроса в хранилище ключей — независимо от того, завершился тот запрос успешно или нет.

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

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

§6Лимиты и Retry-After

Ограничение частоты защищает сервис от одного невнимательного клиента, из-за которого плохо становится всем. Но отказ должен быть понятным: клиент обязан узнать, сколько ему осталось и когда можно снова. Сам код 429 Too Many Requests описан в RFC 6585, раздел 4, а набор сопровождающих заголовков стандартом не закреплён — его берут из практики.

ответ в пределах лимита
HTTP/1.1 201
x-ratelimit-limit: 2
x-ratelimit-remaining: 0
x-ratelimit-reset: 60
следующий запрос — за пределом
HTTP/1.1 429
retry-after: 60
x-ratelimit-limit: 2
x-ratelimit-remaining: 0
x-ratelimit-reset: 60

{ "title": "Too many requests", "status": 429,
  "detail": "Write limit of 2 requests per minute exceeded" }
X-RateLimit-LimitСколько запросов разрешено в окне. Клиент видит потолок заранее, а не узнаёт о нём по отказу.потолок
X-RateLimit-RemainingСколько осталось. Аккуратный клиент притормаживает, не дожидаясь 429.остаток
X-RateLimit-ResetКогда окно обнулится. Здесь — через 60 секунд.сброс
Retry-AfterСколько ждать до повтора. Единственный из четырёх, описанный в RFC как часть ответа 429.пауза

Zalando RESTful API Guidelines, правило 153 «MUST use code 429 with headers for rate limits»правила компании

«X-RateLimit-Reset: The relative time in seconds when the rate limit window will be reset. Beware that this is different to Github and Twitter's usage of a header with the same name which is using UTC epoch seconds instead.»

Перевод. X-RateLimit-Reset — относительное время в секундах до сброса окна ограничения. Учтите, что это отличается от того, как заголовок с тем же именем используют GitHub и Twitter: там указываются секунды UTC epoch.

Секунды или момент времени?

Различие настолько известное, что свод Zalando предупреждает о нём прямо в тексте правила. Клиент, написанный под одно прочтение, сломается на другом: «60» он поймёт как «через минуту», а «1789516800» — как «через полвека». Единицу измерения нужно указывать в описании операции явно; действующий черновик IETF draft-ietf-httpapi-ratelimit-headers как раз пытается свести оба варианта к одному.

Документация GitHub о заголовках x-ratelimit-limit, remaining, used и reset
Заголовки лимитов у GitHub. Тот же набор имён, но x-ratelimit-reset — момент времени, а не число секунд. Наглядный пример того, почему одинаковые имена заголовков ещё не означают одинакового поведения.

§7X-Request-Id: сшивка журналов

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

app/middleware.py · проверка присланного значения
VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$")

incoming = request.headers.get("X-Request-Id")
request_id = incoming if incoming and VALID_REQUEST_ID.match(incoming) else str(uuid4())

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

клиент прислал свой идентификатор
HTTP/1.1 200
etag: "4fec2a976840743a"
x-request-id: support-case-1842
КтоИмя заголовкаОсобенность
Учебный проектX-Request-IdПринимает значение клиента, иначе выдаёт своё
Zalando (правило 233)X-Flow-IDОдин идентификатор на всю цепочку вызовов между сервисами
Azurex-ms-client-request-idОтдельно клиентский идентификатор и отдельно серверный

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

§8Почему браузер не видит заголовки

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

app/main.py · настройка CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    expose_headers=["ETag", "Location", "X-Request-Id", "Retry-After",
                    "Deprecation", "Sunset",
                    "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"],
)
как это выглядит в ответе
access-control-allow-origin: http://localhost:5173
access-control-expose-headers: ETag, Location, X-Request-Id, Retry-After, Deprecation,
                               Sunset, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset

Отсюда практическое правило: добавляя в контракт новый заголовок ответа, проверьте, нужен ли он веб-клиенту. Если нужен, впишите его в expose_headers тем же изменением, иначе заголовок будет существовать для curl и не существовать для страницы.

Блок Response headers в Swagger UI с etag, location и x-request-id
Проверка из браузера. Swagger UI — тоже страница, и он видит только те заголовки, которые сервис разрешил показывать. Если заголовок есть в curl, но не виден здесь, дело в настройке CORS.

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

ETag есть, а If-Match не проверяетсяЗаголовок отдаётся «для галочки», потерянные изменения продолжаются. Механизм работает только целиком.
Ключ идемпотентности без привязки к телуТогда другой запрос с тем же ключом молча вернёт чужой результат вместо честной ошибки.
429 без Retry-AfterКлиент не знает, сколько ждать, и продолжает опрашивать сервис, создавая нагрузку, от которой и защищает лимит.
Единица измерения лимита не описана«60» — это секунды или момент времени? Клиент угадает неправильно.
Чужой X-Request-Id без проверкиВ журнал попадает произвольная строка клиента. Нужны ограничение длины и набор символов.
Новый заголовок не добавлен в expose_headersВ терминале работает, на странице — нет, и разбор занимает полдня.

§10Проверьте себя

  1. Что означает совпадение ETag у двух ответов?
  2. Чем отличаются If-None-Match и If-Match по смыслу и по коду ответа?
  3. Опишите словами, как 412 предотвращает потерянное изменение.
  4. Почему Idempotency-Key связывают с телом запроса, а не только с адресом?
  5. Какие четыре заголовка сопровождают ограничение частоты и что говорит каждый?
  6. Клиент видит x-ratelimit-reset: 1789516800. Как понять, секунды это или момент времени?
  7. Заголовок виден в curl, но не виден на странице. В чём причина и где это чинится?
Версия ресурсаETag — версияIf-None-Match → 304If-Match → 412
ПовторыIdempotency-Keyключ + телохранение 24 часа
ЧастотаX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-ResetRetry-After → 429
Поиск в журналеX-Request-Idпроверять присланное
Для браузераexpose_headers

Справочник «Стандартизация HTTP API» · модуль 4 · глава 4.3Макаров Максим Николаевич

Кадр