Попробовать API
</>
Три кита production-ready API: idempotency, cursor, retry
api-dizaynКейс
14 мин

Три кита 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-клиенте это одна строка в запросе:

bash
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:

php
// 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). Курсор для клиента — чёрный ящик:

sql
-- 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 не уникален — иначе две записи с одинаковым временем могли бы попасть на стык двух страниц.

Формат ответа:

json
{
    "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 с четырьмя ступенями:

ПопыткаЗадержка от первойСценарий
10 (сразу)основная попытка
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: 2

X-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:

sql
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, и по безопасности мутирующих эндпоинтов.