हर बड़ा वेबहुक प्रोवाइडर — Stripe, GitHub, Shopify, Twilio, Slack — जानबूझकर एक ही इवेंट को एक से अधिक बार भेजता है। वे इसे at-least-once delivery कहते हैं। यदि आपका हैंडलर इसके लिए डिज़ाइन नहीं है, तो आपको दोहरे चार्ज वाले ग्राहक, डुप्लिकेट GitHub PR लेबल और दो बार फ़ायर हुए Slack नोटिफिकेशन मिलेंगे। समाधान छोटा और कम आँका गया है: हर हैंडलर को बार-बार डिलीवरी के तहत सुरक्षित डिज़ाइन करें।
डुप्लिकेट क्यों होते हैं
जब प्रोवाइडर समय पर साफ़ 2xx नहीं पाते तो वे रीट्राई करते हैं। रीट्राई विंडो भिन्न होती है — Stripe: एक्सपोनेंशियल backoff के साथ 3 दिन तक, 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 };
}
महत्वपूर्ण रूप: insert और हैंडलर लॉजिक को एक साथ atomic होना चाहिए, या insert पहले आना चाहिए। यदि आप पहले handleEvent कॉल करते हैं और बाद में ID डालते हैं, तो हैंडलर के बीच में crash कोई रिकॉर्ड नहीं छोड़ता और रीट्राई दोबारा प्रोसेस करेगा।
dedup कुंजी कहाँ संग्रहीत करें
- बिज़नेस राइट के समान DB ट्रांज़ैक्शन। सर्वोत्तम। dedup कुंजी और साइड-इफ़ेक्ट या तो दोनों commit होते हैं या कोई नहीं। इवेंट ID कॉलम पर unique constraint इस्तेमाल करें।
- लंबे TTL वाला Redis। कम-जोखिम वाले इवेंट के लिए ठीक। TTL को जिस भी प्रोवाइडर से आप इंटीग्रेट करते हैं उसकी सबसे लंबी रीट्राई विंडो से मिलाएँ — 7 दिन से शुरू करें।
- इन-मेमोरी कैश। मत करें। आपका प्रोसेस रीस्टार्ट होता है, कैश चला जाता है, और अगला रीट्राई पहले से चले हुए हैंडलर को दोबारा चलाने में सफल हो जाता है।
"non-idempotent" वास्तव में कैसा दिखता है
एक असली उदाहरण जो हमें मिला: एक Stripe invoice.payment_succeeded हैंडलर जो सीधे account.credit(amount) कॉल करता था। हर सफल भुगतान बैलेंस जोड़ता था। ज़्यादातर बार ठीक एक बार। कभी-कभी दो बार जब कोई रीट्राई वही इवेंट पकड़ता। हमने इसे इसलिए पाया क्योंकि एक ग्राहक ने विनम्रता से ईमेल कर पूछा कि उसके 50 डॉलर के इनवॉइस ने उसके खाते में 100 डॉलर क्यों क्रेडिट किए।
समाधान Stripe की रीट्राई नीति बदलना या प्रोवाइडर से लड़ना नहीं था। यह events_processed.stripe_event_id पर एक unique index और हैंडलर के शीर्ष पर एक guard था। पूरा पैच ग्यारह लाइनें था।
हैंडलर का समय भी मायने रखता है
प्रोवाइडर के सख़्त timeout होते हैं। GitHub: 10 सेकंड। Discord इंटरैक्शन एंडपॉइंट: 3 सेकंड। Stripe और Shopify अधिक उदार हैं (~30 सेकंड) पर धीमे होने पर फिर भी रीट्राई मिलेंगे। पैटर्न एक ही है: तेज़ी से जवाब दें, काम 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 भारी काम संभालता है। वेबहुक हैंडलर मूलतः एक deduper + एक क्यू प्रोड्यूसर है। यह पैटर्न रीट्राई के तहत सही रहता है क्योंकि क्यू प्रोड्यूसर स्वयं आइडम्पोटेंट है (इवेंट ID पर insert-if-new), और कोई भी डबल-enqueue worker में उसी guard द्वारा पकड़ा जाता है।
कृपया इसे लोकल पर टेस्ट करें
आइडम्पोटेंसी टेस्ट करना ठीक वही है जिसके लिए टनल रीप्ले बना था। अपने टनल की रिक्वेस्ट हिस्ट्री में एक इवेंट डिलीवरी कैप्चर करें और इसे अपने लोकल हैंडलर के विरुद्ध दस बार रीप्ले करें। पहला कॉल आपके डेटाबेस में लिखना चाहिए। अगले नौ बिना छुए 200 लौटाने चाहिए। पुष्टि के लिए अपने DB लॉग देखें।
यह वह टेस्ट है जिसे अधिकांश टीमें छोड़ देती हैं और जिसे अधिकांश टीमों को नहीं छोड़ना चाहिए।
प्रोवाइडर-विशिष्ट रीट्राई नीतियाँ
- Stripe: 3 दिन रीट्राई, एक्सपोनेंशियल backoff। 16 प्रयासों के बाद छोड़ देता है।
- GitHub: 8 प्रयास, ~8 घंटों में पूरे। उसके बाद डिलीवरी को विफल चिह्नित करता है।
- Shopify: 48 घंटे, एक्सपोनेंशियल backoff। 48 घंटों में लगातार 19 विफलताओं के बाद वेबहुक सब्सक्रिप्शन निष्क्रिय कर देता है।
- Twilio: डिफ़ॉल्ट रूप से एक रीट्राई, 3 तक कॉन्फ़िगर करने योग्य।
- Slack: backoff के साथ 3 रीट्राई (1 सेकंड, 30 सेकंड, 5 मिनट)।
अपना dedup TTL उस सबसे लंबी विंडो पर सेट करें जिसकी आपको परवाह है, साथ में एक बफ़र।
निष्कर्ष
डुप्लिकेट डिलीवरी एक अनुबंध है, दोष नहीं। यदि आपने ऐसा वेबहुक हैंडलर शिप किया है जो dedup कुंजी के बिना increment, send_email या charge_card कॉल करता है, तो आपने एक बग शिप किया है। इसे पैच करना छोटा है। ग्राहक की शिकायत के बाद पकड़ना महँगा है।
इसे एंड-टू-एंड टेस्ट करने पर अधिक के लिए, रीप्ले डिबगिंग गाइड वर्कफ़्लो कवर करती है। सिग्नेचर सत्यापन इससे पहले आता है — सिग्नेचर गाइड उसे कवर करती है। ठीक इसी वर्कफ़्लो के इर्द-गिर्द बने टनल + रीप्ले के लिए PortPreview की वेटलिस्ट में शामिल हों।