Кожен великий провайдер вебхуків — 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 заради тунелю + повтору, побудованих саме навколо цього воркфлоу.