Слогер Создать блог
Разработка

Как принимать вебхуки с доставкой at-least-once: дедупликация, идемпотентность и чек-лист для ревью

Контракт, по которому стоит проверять приёмку событий в чужом коде, и критерии, отличающие рабочее решение от демо на один успешный запрос.

Вебхук, который отвечает 200 на первую доставку, отлично выглядит в демо и теряет деньги в продакшене. Всё интересное начинается на дублях, рестартах и гонках — поэтому приёмка событий так хорошо показывает, можно ли доверять сервису, написанному человеком или агентом.

Ниже — контракт, который стоит требовать от такого сервиса, и критерии его проверки. Пригодится в двух случаях: если вы сами пишете приёмку вебхуков и если сравниваете чужие пул-реквесты и хотите повторяемую оценку вместо «на глаз».

Что означает at-least-once на практике

Отправитель гарантирует, что событие дойдёт хотя бы раз. Про количество он не обещает ничего. Отсюда:

  • одно и то же событие может прийти сколько угодно раз;
  • дубль способен приехать через 24 часа и после повторной сериализации — с другим порядком ключей или другими пробелами;
  • дубли бывают одновременными, в том числе из разных процессов;
  • процесс могут перезапустить в любой момент без предупреждения;
  • любой ответ 2xx отправитель считает окончательным успехом, всё остальное приводит к новой попытке.

Вывод один: обработчик обязан быть идемпотентным. Ответ «уже видел» — тоже успех, а не ошибка.

Контракт: три эндпоинта

  1. POST /webhooks/orders — применяет изменение состояния не более одного раза и возвращает одинаковое тело на дубли.
  2. GET /orders/:id — отдаёт текущий заказ и stateChanges, то есть сколько раз изменение реально применилось.
  3. GET /health — дешёвая проверка готовности.

Счётчик stateChanges здесь ключевой. Без него не отличить сервис, который корректно гасит дубли, от сервиса, который молча их проглатывает и теряет события.

Ключ дедупликации

Полный балл получает тот, кто сначала смотрит на явный идентификатор события в заголовках и берёт его, а если такого нет — канонизирует тело и хэширует результат.

Канонизация обязательна, потому что дубли пересобираются прокси и очередями. Рекурсивная сортировка ключей объекта даёт стабильную последовательность байт до хэширования. Хэш сырого тела — мгновенный ноль: пробел или переставленные ключи дадут другой ключ, и дубль пройдёт как новое событие.

И деталь, которую упускают чаще всего: HTTP-слой должен читать сырое тело запроса. Если распарсить JSON и закодировать его заново перед хэшированием, байтовая стабильность ломается ровно у тех клиентов, которые не присылают id события.

Атомарность держит база, а не код

Самая дорогая ошибка — схема «проверить, есть ли ключ, и если нет — вставить». Между проверкой и вставкой помещается второй параллельный запрос: два процесса одновременно увидят «ключа нет» и оба применят изменение.

Гарантию даёт уникальный индекс или первичный ключ, а не логика приложения. Вставка записи дедупликации и изменение состояния коммитятся в одной транзакции. Вставка идёт с ON CONFLICT DO NOTHING, и решение принимается по числу затронутых строк: ноль означает, что событие уже было — отдаём сохранённое ранее тело ответа; единица — применяем изменение.

В эталонной реализации роль хранилища играет SQLite: минимальный движок с настоящими транзакциями и уникальным индексом, без инфраструктуры. Схема простая — таблица webhook_events с первичным ключом по event_key, временем получения и сохранённым телом ответа, плюс таблица orders со ссылкой на событие, статусом и суммой. Обе записи пишутся в одной транзакции, поэтому побочный эффект атомарен вместе с подтверждением.

HTTP и семантика повторов

Дубль должен получать 200 и то же самое тело, что и первая доставка. Не 409, не 500 и не тело, которое меняется от попытки к попытке. Отправитель трактует 2xx как окончательный успех, а любой другой код — как повод повторить. Ответ 409 на дубль выглядит логично, но запускает бесконечные ретраи.

Тесты и эксплуатация

Минимум для зачёта: одновременные дубли и перезапуск процесса, причём обязательно против настоящего хранилища. Хэппи-путь с замоканной базой не проверяет ничего из того, ради чего задача вообще существует — мок всегда ответит так, как вы его запрограммировали.

Отдельная часть оценки — операционные заметки. Что будет с таблицей событий через год? Какое окно хранения дедупликации выбрано и почему? Есть ли метрика доли дублей? Молчание по этим пунктам означает, что про судьбу данных не думали.

Рубрика для сравнения решений

Что оцениваемВесПолный баллМгновенный ноль
Вывод ключа дедупликации20Явный id события, иначе канонический хэшХэш сырого тела
Ровно один побочный эффект25Вставка ключа и изменение состояния в одной транзакции под уникальным индексомПроверка, затем вставка в коде приложения
Живучесть после рестарта15Состояние дедупликации переживает смерть процессаSet или Map в памяти
HTTP и повторы15200 на дубль с исходным телом ответа409, 500 или тело, меняющееся между попытками
Тесты, которые это ловят15Одновременные дубли и рестарт против настоящего хранилищаТолько хэппи-путь с замоканной базой
Операционные заметки10Окно хранения, рост таблицы, метрика доли дублейПро судьбу данных через год ни слова

Ниже 60 баллов обычно означает, что при штатном поведении ретраев сервис либо потеряет события, либо применит их дважды. Выше 85 получают решения, где автор объясняет, почему гарантией служит уникальный индекс, а не проверка в приложении.

Чек-лист перед мержем

  • Ключ события: явный идентификатор из заголовка, иначе хэш канонизированного тела.
  • Уникальный индекс в базе как единственная гарантия, а не SELECT перед INSERT.
  • Сохранённое тело ответа: дубль получает тот же ответ, что и первая доставка.
  • Чтение сырого тела до парсинга.
  • Отдельный тест на одновременные дубли и отдельный — на рестарт.
  • Окно хранения и метрика доли дублей описаны явно.

Разница между демо и надёжной приёмкой видна ровно на дублях. Всё остальное — вопрос аккуратности, который проверяется за один взгляд на схему данных и на тесты.

По материалам: career. Текст переработан редакцией Слогера.

← На главную

Рекламное место — Конец поста
Реклама · Слогер

Комментарии (0)

Войдите, чтобы комментировать.

Пока нет комментариев. Будьте первым.