Вебхук, который отвечает 200 на первую доставку, отлично выглядит в демо и теряет деньги в продакшене. Всё интересное начинается на дублях, рестартах и гонках — поэтому приёмка событий так хорошо показывает, можно ли доверять сервису, написанному человеком или агентом.
Ниже — контракт, который стоит требовать от такого сервиса, и критерии его проверки. Пригодится в двух случаях: если вы сами пишете приёмку вебхуков и если сравниваете чужие пул-реквесты и хотите повторяемую оценку вместо «на глаз».
Что означает at-least-once на практике
Отправитель гарантирует, что событие дойдёт хотя бы раз. Про количество он не обещает ничего. Отсюда:
- одно и то же событие может прийти сколько угодно раз;
- дубль способен приехать через 24 часа и после повторной сериализации — с другим порядком ключей или другими пробелами;
- дубли бывают одновременными, в том числе из разных процессов;
- процесс могут перезапустить в любой момент без предупреждения;
- любой ответ 2xx отправитель считает окончательным успехом, всё остальное приводит к новой попытке.
Вывод один: обработчик обязан быть идемпотентным. Ответ «уже видел» — тоже успех, а не ошибка.
Контракт: три эндпоинта
- POST /webhooks/orders — применяет изменение состояния не более одного раза и возвращает одинаковое тело на дубли.
- GET /orders/:id — отдаёт текущий заказ и stateChanges, то есть сколько раз изменение реально применилось.
- 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 и повторы | 15 | 200 на дубль с исходным телом ответа | 409, 500 или тело, меняющееся между попытками |
| Тесты, которые это ловят | 15 | Одновременные дубли и рестарт против настоящего хранилища | Только хэппи-путь с замоканной базой |
| Операционные заметки | 10 | Окно хранения, рост таблицы, метрика доли дублей | Про судьбу данных через год ни слова |
Ниже 60 баллов обычно означает, что при штатном поведении ретраев сервис либо потеряет события, либо применит их дважды. Выше 85 получают решения, где автор объясняет, почему гарантией служит уникальный индекс, а не проверка в приложении.
Чек-лист перед мержем
- Ключ события: явный идентификатор из заголовка, иначе хэш канонизированного тела.
- Уникальный индекс в базе как единственная гарантия, а не SELECT перед INSERT.
- Сохранённое тело ответа: дубль получает тот же ответ, что и первая доставка.
- Чтение сырого тела до парсинга.
- Отдельный тест на одновременные дубли и отдельный — на рестарт.
- Окно хранения и метрика доли дублей описаны явно.
Разница между демо и надёжной приёмкой видна ровно на дублях. Всё остальное — вопрос аккуратности, который проверяется за один взгляд на схему данных и на тесты.
Комментарии (0)
Войдите, чтобы комментировать.
Пока нет комментариев. Будьте первым.