Три кита production-ready API: idempotency, cursor, retry
TL;DR: мы выпустили v1.0 публичного API BOTIX и думали, что закрыли всё важное. За полтора месяца пришли три жалобы от интеграторов: «`POST /messages` дублирует сообщения при retry», «листаю `/contacts` через offset — теряю и добавляю записи», «отправили webhook, не дошёл, следов не вижу». Кейс о том, как мы добавили Idempotency-Key, cursor-пагинацию и retry с dead-letter — три вещи, которые надо было заложить с первого дня.
Коротко
- Idempotency-Key обязателен на всех мутирующих эндпоинтах. Retry без идемпотентности — это генератор дублей в проде. Ключ решает проблему на сервере: одинаковый
Idempotency-Key→ один результат, повтор отдаётся из кеша (24ч). - Cursor вместо offset на всех list-эндпоинтах. Offset ломается на масштабе (O(N) на больших смещениях) и на изменяющейся коллекции (дубли/пропуски). Cursor — O(log N) и предсказуемый снимок. Переход offset→cursor — breaking change, поэтому закладывать надо сразу.
- Retry webhook с конечным окном и dead-letter. Экспоненциальный backoff 1m / 15m / 1h / 24h, после пятой попытки — dead-letter с ручным повтором из дашборда. At-least-once — норма, идемпотентность на приёмнике обязательна.
Idempotency-Key: «работает же» — пока не приходит первый CI/CD
В v1.0 публичный API BOTIX не имел заголовка Idempotency-Key. Аргумент команды: «у нас есть валидаторы, дубликаты по phone отлавливаем». В реальности всё ломается на сетевых сбоях. Клиент отправляет POST, в канале TCP-сброс или timeout балансировщика. Клиент не получил ответа, но запрос дошёл и обработан. Клиент делает retry. На сервере — второе сообщение. Дубль.
Идемпотентность — это свойство операции, при котором повторное выполнение с теми же входными данными даёт тот же результат без побочных эффектов. Для GET/PUT/DELETE она встроена в семантику HTTP (в теории). Для POST — нет: POST по определению создаёт ресурс, и без защиты повторный вызов создаёт второй. Первое агентство использовало стандартный паттерн CI/CD: при 5xx — три ретрая с jitter. У нас был перегруз воркеров, мы возвращали 503, агентство ретраило, на сервер уходило три POST с одинаковым телом. Валидация по phone не помогла — phone один, но события «отправить сообщение» три разных.
Починили в v1.1. Заголовок обязателен на всех мутирующих эндпоинтах: создание контакта, отправка сообщения, импорт каталога, запуск сценария. Не передал — 400 IDEMPOTENCY_KEY_REQUIRED, без альтернатив. Опциональная идемпотентность — это идемпотентность только для дисциплинированных интеграторов; остальные забудут, и в проде появятся дубли. Лучше падать на отсутствии ключа, чем тихо плодить мусор в БД. Клиент перед POST генерирует UUIDv4 — K, при retry подставляет тот же K. Сервер смотрит в Redis (или таблицу btx_idempotency): был ли запрос с этим K. Был — возвращает сохранённый ответ, не выполняя операцию повторно. Не было — выполняет, сохраняет ответ под K на 24 часа.
На голом HTTP-клиенте это одна строка в запросе:
curl -X POST 'https://api.botix.pro/public/v1/contacts' \
-H 'Authorization: Bearer XXXXXXXX…' \
-H 'Idempotency-Key: '"$(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"phone":"+79990000000","name":"Ivan"}'На сервере захват ключа и кеш ответа живут в middleware:
// app/Middleware/IdempotencyMiddleware.php
public function handle(Request $request, Closure $next): Response
{
$key = $request->header('Idempotency-Key');
if (!$key) return $this->error('IDEMPOTENCY_KEY_REQUIRED', 400);
$hash = hash('sha256', $request->getContent());
$cacheKey = "idemp:{$request->project_id}:{$key}";
// Атомарный захват через Redis SET NX
$captured = $this->redis->set($cacheKey, json_encode([
'status' => 'in_progress', 'body_hash' => $hash,
]), 'NX', 'EX', 86400);
if (!$captured) {
$stored = json_decode($this->redis->get($cacheKey), true);
if ($stored['body_hash'] !== $hash) {
return $this->error('IDEMPOTENCY_KEY_REUSE_CONFLICT', 409);
}
if ($stored['status'] === 'in_progress') {
return $this->error('IDEMPOTENCY_KEY_IN_PROGRESS', 409)
->header('Retry-After', '5');
}
return new Response($stored['body'], $stored['status_code'], $stored['headers']);
}
$response = $next($request);
$this->redis->set($cacheKey, json_encode([
'status' => 'done', 'body_hash' => $hash,
'body' => $response->getContent(),
'status_code' => $response->getStatusCode(),
'headers' => $response->headers->all(),
]), 'EX', 86400);
return $response;
}Пять деталей, которые чаще всего упускают.
TTL хранилища. 24 часа — компромисс между «не потерять идемпотентность для долгих retry» и «не раздуть Redis». Меньше суток опасно: retry может прийти через 6 часов из очереди, а ответ уже забыт — и операция выполнится повторно. Обратная сторона фиксированного окна: ключ живёт ровно 24 часа, поэтому попытку в новый календарный день клиент должен делать с новым ключом.
Что именно хранится. Полный ответ — status code + headers + body, а не только тело. Иначе при первом ответе 409 Conflict и retry клиент получит 200 OK (повтор «успешного» возврата из кеша) — что неверно.
Конфликт ключа. Если клиент по багу генератора использовал один K для двух разных операций, сервер ловит это, сравнивая хеш входного тела с хешем первой попытки. Разные хеши — 409 IDEMPOTENCY_KEY_REUSE_CONFLICT. Код входит в публичный список ошибок на developers.botix.pro/error-codes.
Состояние «в процессе». POST с ключом K идёт уже 3 секунды, клиент по таймауту делает retry с тем же K. Запустить вторую операцию — race condition и дубль. Поэтому ключ захватывается атомарно (SET NX в примере выше), а для retry «в процессе» отдаётся 409 IDEMPOTENCY_KEY_IN_PROGRESS с Retry-After: 5. Клиент подождёт — к этому моменту первая попытка завершится, и ответ придёт из кеша.
Где генерировать K — в SDK, не в пользовательском коде. Все три SDK (PHP/Python/Node) автогенерируют Idempotency-Key через uuid4 на каждый мутирующий метод. Ключевое слово — «логическая операция»: ключ привязан к операции в логике приложения, не к одному HTTP-вызову. Отправляете 100 контактов в цикле — каждый контакт своя операция, свой K. Таймаут при отправке одного контакта и retry — та же операция, тот же K. Новый UUID на каждую попытку = потеря идемпотентности и возврат к дублям. Что нельзя класть в Idempotency-Key: auto-increment ID из локальной БД (не уникален между средами), timestamp (два процесса получат одинаковый), хеш тела запроса (совпадёт у двух *разных* логических операций с одинаковыми параметрами — например, два повторных списания одной суммы). Только UUIDv4 или ULID.
Про production-ready POST целиком — idempotency, retry, rate-limit есть отдельный разбор; здесь важно одно: это не «опция надёжности на потом», а базовое свойство контракта POST-эндпоинта. Сетевая ошибка — это не if, это when.
Cursor вместо offset: как мы переходили на ходу
Вторая жалоба — от агентства с проектом на миллион контактов. Воркер бежал ?offset=0&limit=100, потом offset=100, offset=200. На страницах 200–300 начались дубли и пропуски. На странице 5000 запрос вис по таймауту.
Причина — сама архитектура offset. SELECT ... LIMIT 100 OFFSET 500000 для БД означает: отсортируй полмиллиона, отбрось и верни следующие 100. Даже с индексом БД проходит полмиллиона строк, чтобы их пропустить. Вторая беда — race condition: клиент запросил страницу 1 (записи 1–100), в начало добавились 5 новых (сортировка DESC), клиент запрашивает offset=100 — сервер возвращает позиции 106–205. Записи 101–105 клиент получит дважды, записи 96–100 — не получит вообще. На лентах, листингах, журналах сообщений это известный артефакт offset, и от него не избавиться, пока остаёшься в парадигме offset.
В v1.1.0 все list-эндпоинты перешли на cursor. Вместо «пропусти N записей» клиент говорит «дай записи после такого-то значения», где значение — непрозрачная base64-строка с зашитой парой (created_at, id). Курсор для клиента — чёрный ящик:
-- Cursor-запрос: O(log N) независимо от позиции в коллекции
SELECT * FROM contacts
WHERE project_id = ?
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 100Здесь критичны две вещи. Индекс по (project_id, created_at, id) делает выборку логарифмической: сервер не «пропускает» строки, а сразу прыгает в нужную точку индекса. Пара (created_at, id) гарантирует уникальность позиции даже когда created_at не уникален — иначе две записи с одинаковым временем могли бы попасть на стык двух страниц.
Формат ответа:
{
"data": [...],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0yMlQxNDozNyIsImlkIjoxNTQyfQ==",
"has_more": true
}Для следующей страницы клиент отправляет GET /public/v1/contacts?cursor=eyJj...&cursor_limit=100. Когда has_more=false — обход завершён. cursor_limit управляет размером страницы (по умолчанию 20, максимум 200). Фильтры применяются до курсора: если клиент меняет фильтр посреди обхода, курсор от предыдущей выборки уже невалиден — он относился к другому множеству, и сервер обязан вернуть 400 INVALID_CURSOR, а не произвольный кусок отфильтрованной коллекции.
Race condition при cursor почти исчезает. Новые записи (свежий created_at) имеют меньший курсор-приоритет и не попадут в текущий обход — клиент получит снимок коллекции на момент начала. Удаление записи с позиции курсора тоже не ломает следующий запрос: БД просто пропустит её.
Минусы честно перечислю. Нельзя «прыгнуть на страницу 47» — только последовательный обход (для UI-листингов с номерами страниц это проблема, решается «бесконечной лентой» либо offset для UI с пониманием его границ). Нельзя дёшево показать «всего 12 354 записи» — COUNT(*) на большой таблице сам по себе тяжёлый, счётчик держат отдельно. И серверная реализация сложнее: курсор надо кодировать/декодировать (base64 от JSON), валидировать и подписывать HMAC — чтобы клиент не подменил его и не дотянулся до чужих данных.
Что сломалось при миграции: один клиент держал offset в кеше. Добавили ?pagination=offset как deprecated-режим на 6 месяцев с заголовком X-API-Notice: pagination=offset deprecated, migrate by 2026-11-01 и email-рассылкой по подписчикам developers.botix.pro/changelog/rss. Урок: делать сначала offset, потом мигрировать на cursor — это breaking change, который переживают болезненно и вы, и интеграторы. Если проектируете публичный API, который проживёт дольше года, — закладывайте cursor сразу (мы этот подход к проектированию эндпоинтов под масштаб вывели именно из этого кейса). Цифры: P95 latency листинга 1000 контактов через offset 320мс → через cursor 90мс. На странице 5000 через offset — таймаут 30 секунд, через cursor — те же 90мс независимо от позиции.
Retry webhook с dead-letter: 1m / 15m / 1h / 24h
Третья жалоба: «отправили webhook, не дошёл, следов не вижу». Между «отправили» и «приняли» — длинный список отказов: сервер клиента упал, развернулся релиз, истёк сертификат, реверс-прокси отдал 503, DNS не разрешается, connect timeout. Без retry клиент теряет события; с неправильным retry переполняется очередь и приходят повторы недельной давности.
Сначала — где живёт retry-логика. Есть два места: у отправителя (платформа) и у получателя (клиент). Retry на стороне получателя — клиент принимает webhook, ставит в свою очередь, отвечает 200, обрабатывает асинхронно со своими ретраями. Это правильно для *финальной* обработки, но не заменяет ретраи отправителя: если клиент вообще не ответил 200 (timeout, упавший сервер, 5xx), webhook не попал в его очередь — ретраить нечего. Поэтому платформа хранит каждый webhook в своей БД до подтверждения доставки и повторяет, пока не получит 2xx. Правильно — оба механизма одновременно: платформа гарантирует доставку до 2xx, дальше ответственность у клиента.
В v1.1.0 — экспоненциальный backoff с четырьмя ступенями:
| Попытка | Задержка от первой | Сценарий |
|---|---|---|
| 1 | 0 (сразу) | основная попытка |
| 2 | +1 минута | временный сбой сети |
| 3 | +15 минут | короткий простой / рестарт |
| 4 | +1 час | средний инцидент / релиз |
| 5 | +24 часа | длительный простой / выходные |
Общее окно — около 25 часов: достаточно, чтобы дежурный заметил проблему, починил и сервер начал принимать запросы. После пятой попытки webhook переходит в dead-letter — таблица btx_webhook_dead_letter, без автоматических ретраев. Из dead-letter доставку можно запустить вручную через дашборд на app.botix.pro/developers.
Почему фиксированные интервалы, а не экспонента с jitter. Четыре фиксированных интервала проще объяснить клиенту в документации и проще оценить «когда у меня в худшем случае закроется webhook». Экспонента с jitter даёт ровнее нагрузку на retry-очередь, но усложняет SLA-обещание. Мы выбрали предсказуемость.
Что считается успехом — любой 2xx (200, 201, 202, 204). Что ретраится: connect timeout (10 сек), read timeout (30 сек), любой 5xx, 429 с Retry-After (следующая попытка берётся из заголовка, а не из таблицы). Что не ретраится: 4xx кроме 429. Клиент сам сказал «не приму» — ретраи бессмысленны. Если endpoint вернул 401 (клиент сменил webhook-токен и не обновил у себя), webhook сразу в dead-letter, чтобы владелец проекта увидел проблему, а не получал бесконечный пинг.
Каждая попытка логируется в очереди доставок: целевой URL, payload, HTTP-код, тело ответа (первые 4 KB), request_id, время, номер попытки. Дашборд Webhook Delivery — отдельная вкладка в /developers: список попыток за последние 14 дней с фильтром по статусу, кнопка «Повторить отправку» (тот же webhook с новым request_id — догнать пропущенное после починки сервера) и «Перенаправить на webhook.botix.pro» (встроенный инспектор — посмотреть тело в безопасном окружении, не дёргая прод). Без дашборда dead-letter — просто строки в БД, которые никто не смотрит.
Сам webhook несёт заголовки, по которым клиент маршрутизирует и проверяет подлинность:
X-Botix-Event: payment.succeeded
X-Botix-Signature: 7d4a8c…
X-Botix-Request-Id: req_01HQRX…
X-Delivery-Attempt: 2X-Botix-Signature — HMAC-SHA256 тела, его надо сверять на своей стороне (webhook-secret не публиковать). X-Delivery-Attempt сразу показывает, что это ретрай, а не первая попытка. Тип события (contact.created, payment.succeeded, subscription.cancelled) — обязательное поле для маршрутизации. Два правила по нагрузке: payload ограничен 64 КБ — если события нужно больше, отправляется только id, а полный объект клиент запрашивает через API; порядок доставок не гарантируется — при параллельных webhook'ах клиент должен опираться на timestamp в теле, а не на порядок прихода.
Идемпотентность остаётся обязательной на стороне клиента. At-least-once delivery — стандарт для webhook, дубль неизбежен: клиент обработал событие, начал отвечать 200, connection порвался до того как платформа получила ответ — платформа ретраит, клиент получает повтор. Защита — event.id в теле и хранилище обработанных id:
INSERT INTO processed_events (event_id, processed_at)
VALUES (?, NOW())
ON DUPLICATE KEY UPDATE event_id = event_id;Вставил строку — событие новое, обрабатывать. Ничего не вставилось — дубль, игнорировать. Эта же проверка спасает и от дублей не из-за ретрая (например, два воркера подхватили задачу). Механику подписи и идемпотентности приёмника мы разбираем в архитектуре доставки webhook. Цифры: dead-letter-очередь в среднем 12 webhook'ов в сутки на 50 проектов. Большинство — 403 Forbidden от клиентов с истёкшим webhook-secret. Сразу видно в дашборде.
Что бы сделали иначе
Все три механизма надо было заложить с первого дня v1.0. Idempotency-Key — потому что любая интеграция в реальной сети столкнётся с сетевыми ошибками, это не if, это when. Cursor — потому что offset работает только до определённого масштаба, а переход offset→cursor это breaking change. Retry с dead-letter — потому что без них события теряются, и о факте потери никто не узнаёт. Общий вывод: надёжность API — это не «фичи на потом», а часть контракта. Тот же принцип мы держим и по входному контролю данных на POST, и по безопасности мутирующих эндпоинтов.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Production-ready POST: idempotency, retry и rate-limit
«Мелочи» публичного API: ошибки, cursor, rate-limit
Как надёжно доставлять webhook: подпись, retry, симулятор
Cursor-пагинация, polling vs webhook, audit-логи: три паттерна чтения из публичного API
5 правил API-клиента, который не сломается в проде
Журнал для бизнеса — botix.pro/news
