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

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

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

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

```http
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/batch`                                      | `POST /quote` — ничего не создаёт           |
| `POST /bookings/{id}/cancel`, `/restore`, `/move`, `/together`                | `POST /bookings/check` — ничего не создаёт  |
| `POST /bookings/{id}/invoices`, `POST /invoices/{id}/cancel`                  | `POST /webhooks/{id}/test` — ничего не меняет |
| `POST /webhooks`, `POST /webhooks/{id}/rotate-secret`, `POST /webhooks/{id}/redeliver` | `PATCH` и `DELETE` — их повтор и так безопасен |

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

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

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

:::caution{title="Исправили данные — новый ключ"}

Отказ `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 секунд.

<CodeTabs>

```js title="JavaScript"
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 не ответило — повторите позже с тем же ключом");
}
```

```python title="Python"
import os
import time
import requests

RETRY = {429, 500, 503}

def post_with_retry(url, body, idempotency_key, attempts=6):
    for i in range(attempts):
        try:
            res = requests.post(
                url,
                json=body,
                headers={
                    "Authorization": f"Bearer {os.environ['ZB_KEY']}",
                    "Idempotency-Key": idempotency_key,  # один и тот же на все попытки
                },
                timeout=40,
            )
        except (requests.ConnectionError, requests.Timeout):
            time.sleep(2 ** i)  # обрыв связи или таймаут
            continue
        if res.status_code in RETRY:
            time.sleep(int(res.headers.get("Retry-After", 2 ** i)))
            continue
        return res  # 2xx или окончательный отказ 4xx
    raise RuntimeError("API не ответило — повторите позже с тем же ключом")
```

```php title="PHP"
function postWithRetry(string $url, array $body, string $idempotencyKey, int $attempts = 6): array {
    for ($i = 0; $i < $attempts; $i++) {
        $headers = [];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . getenv('ZB_KEY'),
                'Idempotency-Key: ' . $idempotencyKey, // один и тот же на все попытки
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 40,
            CURLOPT_HEADERFUNCTION => function ($ch, $h) use (&$headers) {
                $p = explode(':', $h, 2);
                if (count($p) === 2) $headers[strtolower(trim($p[0]))] = trim($p[1]);
                return strlen($h);
            },
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        if ($raw === false) { sleep(2 ** $i); continue; } // обрыв связи или таймаут
        if (in_array($status, [429, 500, 503], true)) {
            sleep((int)($headers['retry-after'] ?? 2 ** $i));
            continue;
        }
        return [$status, json_decode($raw, true)]; // 2xx или окончательный отказ 4xx
    }
    throw new RuntimeException('API не ответило — повторите позже с тем же ключом');
}
```

</CodeTabs>

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

Ключ повтора защищает от повтора **одного и того же запроса**. Если ваша 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` сравнивайте только на равенство и передавайте как есть, не разбирая: её вид может
поменяться.
