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 проходит три испытания, и это три разных класса отказов:
- Сетевой сбой клиента. Клиент не получил ответ, делает retry → дубль в БД.
- Сбой получателя webhook. Сервер интегратора упал на час → события потеряны.
- Всплеск нагрузки. Один клиент написал воркер без ограничений → платформа уложилась.
Если любой из трёх случаев не закрыт на сервере и в 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 часа, отдаёт клиенту.
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 из кеша:
// 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_progress → 409 IDEMPOTENCY_KEY_IN_PROGRESS + Retry-After: 5. Клиент подождёт, к моменту повтора первая попытка завершится, ответ придёт из кеша.
Где генерировать K. В SDK, не в коде интегратора. Все три SDK (PHP/Python/Node) автогенерируют ключ через uuid4 на каждый мутирующий метод — разработчик, использующий SDK, об идемпотентности не думает, она встроена:
# 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 с четырьмя ступенями:
| Попытка | Задержка | Что закрывает |
|---|---|---|
| 1 | 0 (сразу) | основная попытка |
| 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:
-- На стороне клиента (приёмник 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 и 503Limit — допускается в текущем окне (обычно минута), 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:
# Минимум: реагируем на 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.Retry — backoff_factor и respect_retry_after_header.
Зрелый паттерн — проактивный: реагируем не после 429, а тормозим заранее по X-RateLimit-Remaining:
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.
Ограничения нашей реализации
Честно про то, что не закрыто:
- Idempotency TTL фиксированный 24 часа. Крупные интеграторы просят 72. Параметризацию через тариф откладываем.
- Async-режим bulk-эндпоинтов отсутствует. Bulk-POST синхронные; при 10 000 контактов и timeout 60 сек нужен async с job-id.
- 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» — здесь.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
5 правил API-клиента, который не сломается в проде
Три кита production-ready API: idempotency, cursor, retry
Как надёжно доставлять webhook: подпись, retry, симулятор
Idempotency-Key, rate-limit и audit-лог публичного API
