Wszystkie artykuły
webhook designidempotencyretriesdistributed systems

Ponowienia webhooków i idempotencja bez łez

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.

Najczęściej zadawane pytania

Dlaczego dostawcy webhooków wysyłają to samo zdarzenie dwa razy?
Dostawcy używają dostawy co najmniej raz. Jeśli nie dostaną odpowiedzi 2xx na czas, ponawiają — nawet gdy oryginalny handler faktycznie się udał, ale odpowiedź zaginęła. Traktuj zduplikowaną dostawę jako gwarantowaną cechę systemu, nie przypadek brzegowy.
Jak uczynić handler webhooka idempotentnym?
Użyj ID zdarzenia wysyłanego przez dostawcę (Stripe evt_xxx, nagłówek X-GitHub-Delivery GitHuba, X-Shopify-Webhook-Id Shopify) jako klucza deduplikacji. Wstaw go z ograniczeniem unikalności na początku handlera; jeśli insert zawiedzie, bo już istnieje, zwróć 200 bez ponownego uruchamiania logiki biznesowej.
Jak długo trzymać klucz dedup?
Co najmniej tak długo jak najdłuższe okno ponowień dostawcy. Stripe ponawia 3 dni, Shopify 48 godzin. TTL 7 dni pokrywa wszystkich dużych dostawców z zapasem. Jeśli masz na to miejsce, trzymaj go na zawsze — przydaje się też do audytu.