Каждый крупный провайдер вебхуков — Stripe, GitHub, Shopify, Twilio, Slack — намеренно доставляет одно и то же событие более одного раза. Это называют доставкой как минимум один раз. Если ваш обработчик не рассчитан на это, вы получите дважды списанных клиентов, дублирующие метки PR в GitHub и дважды отправленные уведомления Slack. Решение маленькое и недооценённое: проектируйте каждый обработчик безопасным к повторной доставке.
Почему возникают дубликаты
Провайдеры повторяют, когда вовремя не получают чистый 2xx. Окно повторов разное — Stripe: до 3 дней с экспоненциальным backoff, GitHub: 8 попыток за ~8 часов, Shopify: 48 часов. Чаще всего исходная доставка действительно прошла; сеть просто потеряла ответ на обратном пути. Провайдер этого не знает. Поэтому повторяет. Поэтому ваш обработчик отрабатывает дважды.
Это не баг, который нужно чинить; это свойство системы. Стройте с учётом этого.
Паттерн идемпотентности, который реально работает
Каждое событие крупного провайдера содержит уникальный ID события. Stripe: evt_xxx. GitHub: заголовок X-GitHub-Delivery (UUID). Shopify: X-Shopify-Webhook-Id. Сохраните ID при первой обработке события. При повторе ищите его — если уже видели, верните 200 без повторного запуска обработчика.
async function handleWebhook(event) {
const eventId = event.id;
// Atomic insert-if-not-exists
const inserted = await db.processedEvents.insertIfNew({
eventId,
receivedAt: new Date(),
});
if (!inserted) {
// We've handled this one. Be polite about it.
return { status: 200, body: 'already processed' };
}
// First time we've seen it. Do the work.
await actuallyHandleEvent(event);
return { status: 200 };
}
Важная форма: вставка и логика обработчика должны быть атомарны вместе, либо вставка должна идти первой. Если вы сначала вызовете handleEvent, а потом вставите ID, краш посреди обработчика не оставит записи, и повтор обработает заново.
Где хранить dedup-ключ
- Та же транзакция БД, что и бизнес-запись. Лучше всего. Dedup-ключ и побочный эффект либо коммитятся оба, либо ни один. Используйте уникальное ограничение на колонке ID события.
- Redis с длинным TTL. Подходит для некритичных событий. Подберите TTL под самое длинное окно повторов любого провайдера, с которым интегрируетесь — начните с 7 дней.
- In-memory кэш. Нет. Процесс перезапустится, кэш пропадёт, и следующий повтор успешно перезапустит уже отработавший обработчик.
Как «не идемпотентно» выглядит на практике
Реальный пример из нашего опыта: обработчик Stripe invoice.payment_succeeded, который напрямую вызывал account.credit(amount). Каждый успешный платёж добавлял баланс. Чаще всего ровно один раз. Иногда дважды, когда повтор ловил то же событие. Мы нашли это, потому что клиент вежливо спросил по почте, почему его счёт на 50 $ зачислил на его счёт 100 $.
Решением было не менять политику повторов Stripe и не бороться с провайдером. Это был уникальный индекс на events_processed.stripe_event_id и страж в начале обработчика. Весь патч — одиннадцать строк.
Тайминг обработчика тоже важен
У провайдеров жёсткие таймауты. GitHub: 10 секунд. Эндпоинты взаимодействий Discord: 3 секунды. Stripe и Shopify снисходительнее (~30 секунд), но повторы всё равно будут, если вы медленны. Паттерн одинаковый: отвечайте быстро, работу делайте асинхронно.
async function handleWebhook(event) {
if (await alreadyProcessed(event.id)) {
return { status: 200 };
}
// Don't await the heavy work — queue it.
await queue.enqueue('process-stripe-event', event);
// 200 immediately. Worker picks up the event.
return { status: 200 };
}
Воркер очереди делает медленное. Обработчик вебхука — по сути дедупер + продюсер очереди. Этот паттерн остаётся корректным при повторах, потому что сам продюсер очереди идемпотентен (insert-if-new по ID события), а любое двойное добавление в очередь ловится тем же стражем в воркере.
Протестируйте это локально, пожалуйста
Тестирование идемпотентности — именно то, для чего создавался повтор через туннель. Захватите доставку события в истории запросов туннеля и повторите её десять раз против локального обработчика. Первый вызов должен записать в БД. Следующие девять — вернуть 200, не трогая её. Смотрите логи БД для подтверждения.
Это тест, который большинство команд пропускает и который большинству команд пропускать не стоит.
Политики повторов по провайдерам
- Stripe: повторяет 3 дня, экспоненциальный backoff. Прекращает после 16 попыток.
- GitHub: 8 попыток, завершаются за ~8 часов. После этого помечает доставку как неудачную.
- Shopify: 48 часов, экспоненциальный backoff. Отключает подписку на вебхук после 19 подряд неудач за 48 часов.
- Twilio: один повтор по умолчанию, настраивается до 3.
- Slack: 3 повтора с backoff (1 с, 30 с, 5 мин).
Установите dedup-TTL на самое длинное окно, которое вас волнует, плюс запас.
Итог
Дублирующая доставка — это контракт, а не дефект. Если вы выкатили обработчик вебхука, вызывающий increment, send_email или charge_card без dedup-ключа, вы выкатили баг. Запатчить его — мелочь. Поймать после жалобы клиента — дорого.
Подробнее о сквозном тестировании этого — в руководстве по отладке через повтор. Проверка подписи стоит до этого — её разбирает руководство по подписи. Присоединяйтесь к листу ожидания PortPreview ради туннеля + повтора, построенных вокруг именно этого воркфлоу.