Każdy duży dostawca webhooków — Stripe, GitHub, Shopify, Twilio, Slack — celowo dostarcza to samo zdarzenie więcej niż raz. Nazywają to dostawą co najmniej raz. Jeśli twój handler nie jest na to przygotowany, dostaniesz podwójnie obciążonych klientów, zduplikowane etykiety PR GitHuba i dwa razy wysłane powiadomienia Slacka. Poprawka jest mała i niedoceniana: zaprojektuj każdy handler tak, by był bezpieczny przy powtórnej dostawie.
Dlaczego powstają duplikaty
Dostawcy ponawiają, gdy nie dostaną czystego 2xx na czas. Okno ponowień się różni — Stripe: do 3 dni z wykładniczym backoffem, GitHub: 8 prób przez ~8 godzin, Shopify: 48 godzin. Najczęściej oryginalna dostawa się udała; sieć po prostu zgubiła odpowiedź w drodze powrotnej. Dostawca tego nie wie. Więc ponawia. Więc twój handler działa dwa razy.
To nie bug do naprawienia; to właściwość systemu. Buduj z myślą o tym.
Wzorzec idempotencji, który naprawdę działa
Każde zdarzenie dużego dostawcy zawiera unikalny ID zdarzenia. Stripe: evt_xxx. GitHub: nagłówek X-GitHub-Delivery (UUID). Shopify: X-Shopify-Webhook-Id. Zapisz ID przy pierwszym przetworzeniu zdarzenia. Przy ponowieniu wyszukaj je — jeśli już je widziałeś, zwróć 200 bez ponownego uruchamiania handlera.
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 };
}
Kluczowa forma: insert i logika handlera muszą być atomowe razem, albo insert musi być pierwszy. Jeśli najpierw wywołasz handleEvent, a potem wstawisz ID, crash w środku handlera nie zostawi rekordu i ponowienie przetworzy ponownie.
Gdzie przechowywać klucz dedup
- Ta sama transakcja DB co zapis biznesowy. Najlepiej. Klucz dedup i efekt uboczny commitują się oba albo żaden. Użyj ograniczenia unikalności na kolumnie ID zdarzenia.
- Redis z długim TTL. OK dla zdarzeń o niskiej stawce. Dopasuj TTL do najdłuższego okna ponowień każdego dostawcy, z którym integrujesz — zacznij od 7 dni.
- Cache w pamięci. Nie. Proces się restartuje, cache znika, a kolejne ponowienie z powodzeniem ponownie uruchamia handler, który już zadziałał.
Jak wygląda "nie idempotentny" w praktyce
Realny przykład, który nas spotkał: handler Stripe invoice.payment_succeeded, który wywoływał account.credit(amount) bezpośrednio. Każda udana płatność dodawała saldo. Najczęściej dokładnie raz. Czasem dwa razy, gdy ponowienie złapało to samo zdarzenie. Znaleźliśmy to, bo klient grzecznie zapytał mailem, czemu jego faktura na 50 $ zasiliła jego konto kwotą 100 $.
Poprawką nie była zmiana polityki ponowień Stripe ani walka z dostawcą. Był to unikalny indeks na events_processed.stripe_event_id i strażnik na początku handlera. Cała łatka to jedenaście linii.
Timing handlera też ma znaczenie
Dostawcy mają twarde timeouty. GitHub: 10 sekund. Endpointy interakcji Discorda: 3 sekundy. Stripe i Shopify są bardziej wyrozumiałe (~30 sekund), ale i tak dostaniesz ponowienia, jeśli jesteś wolny. Wzorzec jest ten sam: odpowiadaj szybko, pracę rób async.
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 };
}
Worker kolejki zajmuje się powolnymi rzeczami. Handler webhooka to w zasadzie deduper + producent kolejki. Ten wzorzec pozostaje poprawny przy ponowieniach, bo sam producent kolejki jest idempotentny (insert-if-new po ID zdarzenia), a każde podwójne enqueue łapie ten sam strażnik w workerze.
Przetestuj to lokalnie, proszę
Testowanie idempotencji to dokładnie to, do czego stworzono powtórkę tunelu. Przechwyć jedną dostawę zdarzenia w historii żądań tunelu i odtwórz ją dziesięć razy wobec lokalnego handlera. Pierwsze wywołanie powinno zapisać do bazy. Kolejne dziewięć powinno zwrócić 200 bez jej dotykania. Obserwuj logi DB, by potwierdzić.
To test, który większość zespołów pomija, a którego większość zespołów pomijać nie powinna.
Polityki ponowień zależne od dostawcy
- Stripe: ponawia 3 dni, wykładniczy backoff. Rezygnuje po 16 próbach.
- GitHub: 8 prób, zakończonych w ~8 godzin. Potem oznacza dostawę jako nieudaną.
- Shopify: 48 godzin, wykładniczy backoff. Wyłącza subskrypcję webhooka po 19 kolejnych niepowodzeniach w ciągu 48 godzin.
- Twilio: jedno ponowienie domyślnie, konfigurowalne do 3.
- Slack: 3 ponowienia z backoffem (1 s, 30 s, 5 min).
Ustaw TTL dedup na najdłuższe okno, które cię interesuje, plus bufor.
Sedno sprawy
Zduplikowana dostawa to kontrakt, nie defekt. Jeśli wdrożyłeś handler webhooka, który wywołuje increment, send_email lub charge_card bez klucza dedup, wdrożyłeś buga. Załatanie go jest małe. Wykrycie po skardze klienta jest kosztowne.
Więcej o testowaniu tego end-to-end znajdziesz w przewodniku debugowania z powtórką. Weryfikacja podpisu jest przed tym — omawia ją przewodnik o podpisie. Dołącz do listy oczekujących PortPreview, by mieć tunel + powtórkę zbudowane wokół dokładnie tego workflow.