Попробовать API
</>
Production-ready POST: idempotency, retry и rate-limit
api-dizaynИнструкция
14 мин

Production-ready POST: idempotency, retry и rate-limit

TL;DR: POST-эндпоинт без `Idempotency-Key`, без понятной retry-политики и без rate-limit headers — это не публичный API, это бета на стенде. Между «эндпоинт работает» и «готов к интеграторам» лежит чек-лист из трёх пунктов. Разбираю каждый: TTL и поведение при коллизии ключа, серверная retry-политика 1m/15m/1h/24h с dead-letter, заголовки `X-RateLimit-*` и backoff с jitter — с кодом из наших SDK и middleware.

Коротко

  • Idempotency-Key закрывает сетевой сбой клиента. Клиент не получил ответ и делает retry → без ключа это дубль в БД. Ключ обязателен на мутирующих эндпоинтах, TTL 24 часа, в кеше — полная репрезентация ответа (status + headers + body), при коллизии — 409.
  • Серверный retry закрывает простой получателя webhook. Платформа повторяет доставку по ступеням 1m / 15m / 1h / 24h, дальше — dead-letter с ручным повтором через дашборд. 2xx — успех, 5xx/timeout — ретрай, 4xx кроме 429 — сразу dead-letter.
  • Rate-limit headers закрывают всплеск нагрузки. X-RateLimit-Limit/Remaining/Reset в каждом ответе, Retry-After при 429. Клиент делает backoff с экспонентой и jitter, зрелый режим — проактивно тормозит по Remaining, не доводя до 429.

Любой POST-эндпоинт в публичном API проходит три испытания, и это три разных класса отказов:

  1. Сетевой сбой клиента. Клиент не получил ответ, делает retry → дубль в БД.
  2. Сбой получателя webhook. Сервер интегратора упал на час → события потеряны.
  3. Всплеск нагрузки. Один клиент написал воркер без ограничений → платформа уложилась.

Если любой из трёх случаев не закрыт на сервере и в SDK, эндпоинт не готов к интеграторам. Ниже — по механизму на каждый класс, с кодом. Общая логика «клиента, который не сломается в проде» разобрана отдельно — пять правил API-клиента; здесь фокус на серверном контракте.

Пункт 1. Idempotency-Key на POST

Клиент отправляет POST, в канале сбой — TCP-сброс, таймаут балансировщика, обрыв на стороне провайдера. Клиент не получил ответ, но запрос дошёл и обработан. Клиент делает retry. На сервере — второй заказ, второе списание, второе исходящее сообщение. Дубль. Это не *if*, а *when*: любая интеграция в реальной сети рано или поздно попадёт в этот сценарий.

Идемпотентность — свойство операции, при котором повторное выполнение с теми же входами даёт тот же результат без побочных эффектов. Для GET/PUT/DELETE она встроена в семантику HTTP (теоретически). Для POST — нет: POST по определению создаёт ресурс, и без защиты повторный вызов создаст второй. Индустриальный стандарт — Idempotency-Key, уникальный идентификатор запроса в заголовке.

Механика: клиент перед отправкой POST генерирует случайный UUID (K), подставляет в Idempotency-Key, при retry повторяет тот же запрос с тем же K. Сервер смотрит в хранилище (Redis или таблица btx_idempotency): был K — возвращает сохранённый ответ первой попытки без повторного выполнения; не было — выполняет, сохраняет ответ под K на 24 часа, отдаёт клиенту.

mermaid
sequenceDiagram
    participant Client
    participant API as api.botix.pro
    participant Redis

    Client->>API: POST /contacts<br/>Idempotency-Key: K1
    API->>Redis: GET key:K1
    Redis-->>API: NULL
    API->>API: INSERT contact, save response
    API-->>Client: 201 Created
    Note over Client: Сетевой сбой, ответ потерян
    Client->>API: POST /contacts<br/>Idempotency-Key: K1 (RETRY)
    API->>Redis: GET key:K1
    Redis-->>API: {status:201, body:...}
    API-->>Client: 201 Created (из кеша)

В BOTIX Idempotency-Key обязателен на мутирующих эндпоинтах (POST /messages, POST /scenarios/{id}/run и др.). Без него — 400 IDEMPOTENCY_KEY_REQUIRED, без альтернатив. Опциональная идемпотентность — это идемпотентность только для дисциплинированных интеграторов; остальные забудут, и в проде появятся дубли. Лучше падать на отсутствии ключа, чем тихо производить мусор в БД. В Redis хранится полная репрезентация ответа (status + headers + body), не только тело — иначе при первом 409 и retry клиент получит 200 OK из кеша:

php
// app/Middleware/Idempotency.php (упрощённо)
$payload = json_encode([
    'status' => $response->getStatusCode(),
    'headers' => $response->getHeaders(),
    'body' => (string) $response->getBody(),
    'request_hash' => hash('sha256', $request->getBody()),
]);
$redis->setex("idem:{$projectId}:{$key}", 86400, $payload);

TTL 24 часа — компромисс между «не потерять идемпотентность для долгих retry-стратегий» и «не раздувать хранилище». Платёжные системы ставят 24-72ч. Меньше суток опасно: клиент может попасть в очередь, retry прийти через 6 часов, а ответ уже забыт — операция выполнится повторно. Практическое следствие TTL: ключ живёт только сутки, поэтому для нового календарного дня клиент генерирует новый K.

Конфликт ключа. Если K тот же, но тело другое (баг в генераторе) — сравнить sha256(body) с сохранённым. Не совпало → 409 IDEMPOTENCY_KEY_REUSE_CONFLICT (код в публичном списке ошибок, developers.botix.pro/error-codes).

Состояние in_progress. Операция идёт 3 секунды, клиент по таймауту делает retry с тем же K. Если сервер запустит вторую обработку — race condition и возможный дубль. Решение: атомарно захватить ключ при первом запросе (SETNX / INSERT IGNORE / SELECT … FOR UPDATE), а retry в состоянии in_progress409 IDEMPOTENCY_KEY_IN_PROGRESS + Retry-After: 5. Клиент подождёт, к моменту повтора первая попытка завершится, ответ придёт из кеша.

Где генерировать K. В SDK, не в коде интегратора. Все три SDK (PHP/Python/Node) автогенерируют ключ через uuid4 на каждый мутирующий метод — разработчик, использующий SDK, об идемпотентности не думает, она встроена:

python
# github.com/BOTIX-pro/sdk-python/blob/v1.1.0/botix/_http.py
import uuid

def _request(method, path, **kwargs):
    headers = kwargs.setdefault('headers', {})
    if method in ('POST', 'PUT', 'PATCH', 'DELETE'):
        headers.setdefault('Idempotency-Key', str(uuid.uuid4()))
    return _do_request(method, path, **kwargs)

На голом HTTP-клиенте (curl и т.п.) ключ ставится вручную — в примерах доки это -H "Idempotency-Key: $(uuidgen)".

K привязан к логической операции, не к HTTP-вызову. Это место, где интеграторы чаще всего ошибаются. 100 контактов в цикле — 100 разных K (каждый контакт = отдельная операция). Retry одного контакта после таймаута — тот же K (та же операция). Новый UUID на каждую попытку = потеря идемпотентности и возврат к дублям. Что не подходит в качестве ключа: auto-increment ID (не уникален между средами, коллизит), timestamp (два параллельных процесса получат один и тот же), хеш от тела (совпадёт у двух разных операций с одинаковыми параметрами — два повторных списания одной суммы это две операции, не одна). Используйте UUIDv4 или ULID.

Идемпотентность — не «опция надёжности» на потом, а базовое свойство контракта POST. Единственный способ корректно обработать retry на стороне клиента — иметь идемпотентность на стороне сервера.

Пункт 2. Серверный retry webhook'ов: 1m / 15m / 1h / 24h

Второй класс отказов — на стороне получателя. Сервер клиента упал, развернулся новый релиз и endpoint недоступен, истёк сертификат, реверс-прокси отдал 503, DNS не разрешается, connect timeout. Что делает платформа в каждом случае — определяет, надёжна ли интеграция в целом.

Есть два места для повторных попыток: на стороне отправителя (платформы) и получателя (клиента). На стороне получателя клиент принимает webhook, сразу отвечает 200, ставит событие в свою очередь и обрабатывает асинхронно — архитектурно правильно для финальной обработки, но не заменяет ретраи отправителя: если клиент вообще не ответил 200 (timeout, упавший сервер, 5xx), webhook не попал в его очередь, ретраить нечего. Правильная архитектура — оба механизма одновременно. Платформа гарантирует доставку до 2xx, дальше ответственность у клиента.

Слишком частые ретраи — атака на свой же endpoint клиента (он лежит от нагрузки, ещё пятьсот в секунду его не поднимут). Слишком редкие — потерянные часы для бизнеса. В BOTIX — экспоненциальный backoff с четырьмя ступенями:

ПопыткаЗадержкаЧто закрывает
10 (сразу)основная попытка
2+1 минутамгновенный сбой (релоад nginx)
3+15 минуткороткий простой / рестарт
4+1 чассредний инцидент / выкатка
5+24 часадлительный простой / выходные

После пятой попытки webhook переходит в dead-letter — отдельная таблица без автоматических ретраев, доставку запускают вручную через дашборд. Общее окно — около 25 часов: достаточно, чтобы дежурный заметил, починил и сервер начал принимать. Событие за пределами суток уже теряет актуальность, а бесконечные ретраи вредны по двум причинам — размер очереди становится непредсказуемым (один лежащий клиент оставит миллионы записей), и запоздалый webhook «заказ принят» через три дня приносит больше вреда, чем пользы.

Что считается успехом и что ретраится:

  • 2xx (200, 201, 202, 204) — успех, не ретраится.
  • 5xx (500, 502, 503, 504) — ретраится по расписанию.
  • Timeout (connect 10 сек / read 30 сек) — ретраится.
  • 429 с Retry-After — следующая попытка из заголовка, не из таблицы.
  • 4xx кроме 429 (400, 401, 403, 404)не ретраится, сразу dead-letter. 401 значит клиент сменил токен webhook и не обновил у себя — пинговать бессмысленно, лучше показать проблему в дашборде.

Каждая попытка логируется в btx_api_webhook_deliveries: целевой URL, статус ответа, текст ошибки, время, номер попытки. После исчерпания ретраев или 4xx создаётся запись со статусом dead_letter. Дашборд Webhook Delivery в кабинете /developers: список попыток за 14 дней с фильтром по статусу, каждая разворачивается (URL, payload, HTTP-код, тело ответа, request_id, время, номер попытки), кнопки «Повторить отправку» (тот же webhook с новым request_id — догнать пропущенное) и «Перенаправить» на встроенный инспектор webhook.botix.pro (посмотреть тело в безопасном окружении, не дёргая прод). Без дашборда dead-letter — просто строки в БД, которые никто не смотрит.

Идемпотентность на стороне клиента. Ретраи неизбежно приводят к тому, что один webhook доставлен несколько раз (клиент начал отвечать 200, connection порвался до получения ответа платформой). At-least-once delivery — стандарт, не баг. Защита через event_id в теле или X-Event-Id:

sql
-- На стороне клиента (приёмник webhook'ов)
INSERT INTO processed_webhook_events (event_id, processed_at)
VALUES (?, NOW())
ON DUPLICATE KEY UPDATE event_id = event_id;

Если INSERT вставил строку — событие новое, обрабатывать; ничего не вставилось (конфликт по event_id) — дубль, игнорировать. Этот же приём закрывает дубли не только от ретраев, но и от гонки воркеров на стороне платформы.

Пара правил сверху: sequence не гарантируется — при параллельных доставках порядок прихода может отличаться от порядка событий, клиент полагается на timestamp в теле, а не на порядок; размер payload ограничен 64 КБ — если данных больше, отправляется только id, клиент дозапрашивает полный объект через API. Webhook несёт полный набор заголовков:

X-BOTIX-Signature: 7d4a8c...
X-Event-Id: evt_01HQRX...
X-Event-Type: payment.succeeded
X-Delivery-Attempt: 2
X-Request-Id: req_01HQRX...

X-Delivery-Attempt сразу показывает, что это ретрай, а не первая попытка. Полная архитектура доставки (подпись, retry, идемпотентность) — в отдельном разборе про webhook-механику.

Пункт 3. Rate-limit headers и backoff на клиенте

Третий класс — нагрузочный. Любой публичный API ограничивает число запросов на ключ за окно, иначе один интегратор с неоптимальным воркером парализует сервис для всех. Стандартная реакция на превышение — 429 Too Many Requests, и здесь начинается главное недоразумение: увидев 429, многие SDK «повторяют запрос» сразу — получают второй 429, в цикле десятый, а параллельный воркер устраивает лавину ретраев (*thundering herd*). Корректная обработка — не «увидел 429, попробовал снова», а работа с заголовками, которые платформа отдаёт в каждом ответе.

Индустриальный стандарт (Stripe, GitHub, Twitter, Cloudflare) — три заголовка в каждом ответе и четвёртый в 429:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716392460
Retry-After: 13         # только при 429 и 503

Limit — допускается в текущем окне (обычно минута), Remaining — сколько осталось, Reset — Unix-timestamp обнуления, Retry-After (RFC 7231 §7.1.3) — явная команда «не пытайся раньше». В BOTIX все четыре реализованы в PublicApiAuth middleware (app/Middleware/PublicApiAuth.php). Лимит из btx_plans.limits.api_rate_per_minute (триал — 30/мин, бизнес — 2000/мин), счётчик в Redis с TTL до конца текущей минуты.

Минимальный backoff — читать Retry-After при 429 и спать столько, сколько сказала платформа. Но при нескольких параллельных воркерах все разом упрутся в 429, получат один Retry-After: 13, проснутся синхронно и выдадут залп. Чтобы это размазать — jitter (случайная компонента) + экспонента при повторных 429:

python
# Минимум: реагируем на 429 с jitter и экспонентой.
import random, time, requests

def call_with_backoff(method, url, max_retries=5, **kwargs):
    for attempt in range(max_retries):
        r = requests.request(method, url, **kwargs)
        if r.status_code != 429:
            return r
        retry_after = int(r.headers.get('Retry-After', 1))
        # jitter 0..30% размазывает «стаю» воркеров (thundering herd)
        jitter = random.uniform(0, retry_after * 0.3)
        # экспонента 1×, 2×, 4×, 8×, 16×, потолок 60 сек
        time.sleep(min(retry_after * (2 ** attempt) + jitter, 60))
    raise RuntimeError('Rate limit not cleared')

2 ** attempt даёт экспоненциальный рост, jitter размазывает повторы, min(…, 60) — потолок, а падение после N попыток важно, чтобы при систематической проблеме воркер не висел молча. Готовое лучше своего: в tenacity эта логика есть через @retry(stop=stop_after_attempt, wait=wait_exponential_jitter), у urllib3.util.Retrybackoff_factor и respect_retry_after_header.

Зрелый паттерн — проактивный: реагируем не после 429, а тормозим заранее по X-RateLimit-Remaining:

python
def maybe_sleep_proactive(response):
    remaining = int(response.headers.get('X-RateLimit-Remaining', 1000))
    reset = int(response.headers.get('X-RateLimit-Reset', 0))
    if remaining < 5:
        time.sleep(max(0, reset - int(time.time())))

Когда осталось меньше 5 запросов в окне — клиент спит до обнуления, и 429 в нормальной работе не возникает совсем. Для batch (10 000 контактов, лимит 60/мин) это даёт ровную скорость 1 req/sec без всплесков, вместо серий по 60 запросов с двухминутными паузами. При параллели нескольких воркеров с одним ключом локальный счётчик врёт (воркер A думает «осталось 47», воркер B параллельно сделал 50 — оба ловят 429), нужен централизованный счётчик: Redis, aiolimiter (Python), bottleneck (Node).

Чего не делать: игнорировать Retry-After и подменять константой (платформа знает точное окно, клиент — нет); путать 429 с другими 4xx (401 — ключ невалиден, 422 — запрос некорректен, повторять бесполезно); обходить rate-limit ротацией ключей (нарушение условий, ключи привязаны к project_id, лимит считается на уровне ключа — «нужно больше» это сигнал к смене тарифа).

Чек-лист готовности POST-эндпоинта

  • [ ] Idempotency-Key обязателен; без него — 400 IDEMPOTENCY_KEY_REQUIRED.
  • [ ] При коллизии — 409 IDEMPOTENCY_KEY_REUSE_CONFLICT.
  • [ ] При retry in_progress — 409 IDEMPOTENCY_KEY_IN_PROGRESS + Retry-After.
  • [ ] TTL ключа 24 часа, в кеше — полная репрезентация ответа.
  • [ ] Если триггерит webhook — серверный retry 1m/15m/1h/24h + dead-letter.
  • [ ] Webhook несёт X-Event-Id и X-Delivery-Attempt.
  • [ ] 4xx (кроме 429) на webhook → сразу dead-letter.
  • [ ] X-RateLimit-Limit/Remaining/Reset в каждом ответе, Retry-After при 429.
  • [ ] SDK на 3 языках инкапсулируют генерацию K и backoff с jitter.

Ограничения нашей реализации

Честно про то, что не закрыто:

  1. Idempotency TTL фиксированный 24 часа. Крупные интеграторы просят 72. Параметризацию через тариф откладываем.
  2. Async-режим bulk-эндпоинтов отсутствует. Bulk-POST синхронные; при 10 000 контактов и timeout 60 сек нужен async с job-id.
  3. Dead-letter retention 14 дней. Под compliance мало — архивный режим под первого клиента.

Production-ready POST — не «эндпоинт возвращает 200», а три отдельных механизма под три класса отказов: сетевые сбои клиента, простои получателя, нагрузочные всплески. У нас v1.0.0 имела рудиментарный rate-limit без проактивных headers, и переход на нормальный набор в 1.1.0 был breaking change через developers.botix.pro/changelog. Как эти три пункта ложатся в общий свод «трёх вещей для production-ready API» — здесь.