Справка

Повтор запроса

Зачем это нужно

Ваша CRM отправила «оформить бронь», и связь оборвалась. Бронь создалась или нет? Если отправить запрос ещё раз — не будет ли двух броней и двух счетов родителю?

Для этого каждый POST, который что-то создаёт или меняет, несёт заголовок Idempotency-Key — ключ повтора. Лагерь запоминает результат первого запроса с этим ключом. Повтор с тем же ключом не выполняется заново, а получает сохранённый ответ: ту же бронь, тот же счёт.

Code
POST /ext/v1/bookings HTTP/1.1 Authorization: Bearer zb_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX Idempotency-Key: 6f1c2a9e-3b7d-4e0a-9c51-2d8e4f7a1b03 Content-Type: application/json

Правила

  • Ключ — случайная строка, своя на каждое действие. Лучше всего UUID. До 255 знаков.
  • Одно действие — один ключ. «Оформить бронь по заявке 10452» — один ключ на все попытки этого оформления. Следующее действие (выставить счёт по этой брони) — новый ключ.
  • Повтор — с тем же ключом и тем же телом. Ответ будет тот же, что у первого запроса, с заголовком Idempotent-Replayed: true.
  • Тот же ключ с другим телом — отказ 422 idempotency_mismatch. Ключ нельзя переиспользовать для другого действия.
  • Ключи хранятся двое суток. После этого тот же ключ снова свободен.
  • Ключи у каждого подключения свои: ваш ключ не пересечётся с ключом другого отдела.
  • Два одновременных запроса с одним ключом не выполнятся дважды: второй дождётся первого и получит его результат.

Нет заголовка — 400 idempotency_key_required; длиннее 255 знаков — 400 idempotency_key_too_long.

Где ключ обязателен

Нужен Idempotency-KeyНе нужен
POST /bookings, POST /bookings/batchPOST /quote — ничего не создаёт
POST /bookings/{id}/cancel, /restore, /move, /togetherPOST /bookings/check — ничего не создаёт
POST /bookings/{id}/invoices, POST /invoices/{id}/cancelPOST /webhooks/{id}/test — ничего не меняет
POST /webhooks, POST /webhooks/{id}/rotate-secret, POST /webhooks/{id}/redeliverPATCH и DELETE — их повтор и так безопасен

Обязателен ли заголовок у конкретного адреса — видно и в его параметрах в справочнике.

Что запоминается, а что нет

Ответ на первый запросПовтор с тем же ключом
2xx — выполненоТот же ответ, ничего не выполняется заново
4xx (кроме 429) — отказТот же отказ, запрос не выполняется заново
429 — превышен лимитВыполняется заново (подождите Retry-After)
5xx — сбой сервераВыполняется заново или дожидается начатого (см. ниже)
Обрыв связи, ответа нетПолучите результат первого запроса, если он дошёл; иначе запрос выполнится

Исправили данные — новый ключ

Отказ 4xx запоминается вместе с ключом. Если оформление получило 400 birth_date_required, а вы добавили дату рождения — это уже другой запрос: отправьте его с новым ключом. С прежним ключом и новым телом получите 422 idempotency_mismatch, с прежним телом — снова тот же отказ.

Ответ 503 timeout — не значит «не выполнено»

Если лагерь не успел ответить за 30 секунд, придёт 503 с кодом timeout. Но начатое действие доводится до конца и без вас: бронь может оформиться через секунду после этого ответа.

Поэтому на 503 (и на обрыв связи, и на 500) — повторите с тем же ключом. Если действие ещё выполняется, повтор дождётся его и вернёт результат. Если уже выполнилось — вернёт сохранённый ответ. Не создавайте новый ключ на такой повтор — иначе можно получить вторую бронь (или 409 duplicate_child, если первая успела оформиться).

Как устроить у себя

  1. Создайте ключ до отправки и сохраните его в своей базе рядом с действием — например, в задаче «оформить бронь по заявке 10452». Так ключ переживёт и перезапуск вашей CRM.
  2. Отправьте запрос.
  3. Обрыв связи, таймаут, 429, 500, 503 — подождите и повторите с тем же ключом. Пауза: Retry-After, если он есть, иначе 1, 2, 4, 8, 16 секунд.
  4. Получили 2xx — действие выполнено, отметьте задачу.
  5. Получили другой 4xx — показывайте message менеджеру; повторять бесполезно.
  6. Новый ключ — только для нового действия.

Ставьте таймаут вашего HTTP-клиента не меньше 35–40 секунд: лагерь может отвечать до 30 секунд.

const RETRY = new Set([429, 500, 503]); async function postWithRetry(url, body, idempotencyKey, attempts = 6) { for (let i = 0; i < attempts; i++) { try { const res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.ZB_KEY}`, "Idempotency-Key": idempotencyKey, // один и тот же на все попытки "Content-Type": "application/json", }, body: JSON.stringify(body), signal: AbortSignal.timeout(40_000), }); if (RETRY.has(res.status)) { const wait = Number(res.headers.get("Retry-After") ?? 2 ** i); await new Promise((r) => setTimeout(r, wait * 1000)); continue; } return res; // 2xx или окончательный отказ 4xx } catch { await new Promise((r) => setTimeout(r, 2 ** i * 1000)); // обрыв связи или таймаут } } throw new Error("API не ответило — повторите позже с тем же ключом"); }

Вторая защита: свой номер записи

Ключ повтора защищает от повтора одного и того же запроса. Если ваша CRM по ошибке создаст новое действие для той же сделки (новый ключ), бронь оформится второй раз. От этого защищает external_id: передавайте номер сделки при оформлении. Вторая бронь с тем же external_id не оформится — ответ 409 external_id_taken с номером уже существующей брони.

If-Match: не затереть чужую правку

If-Match с версией записи (version) принимают ровно эти операции: PATCH /bookings/{id}, POST /bookings/{id}/cancel и PATCH /families/{id}. Остальные операции заголовок не смотрят.

  1. Прочитайте запись — запомните её version (например, 2027-05-14T10:02:45.918201Z).
  2. Отправьте правку с заголовком If-Match: "2027-05-14T10:02:45.918201Z" (кавычки можно не ставить).
  3. Если запись изменили после вашего чтения — ответ 412 version_mismatch. Перечитайте её, покажите менеджеру новые данные и решите заново.

version сравнивайте только на равенство и передавайте как есть, не разбирая: её вид может поменяться.

Last modified on