รับ Webhook ซ้ำและล่าช้าอย่างไรให้ระบบไม่พลาด
อ่าน 2 นาที
ไม่มีรูปภาพ
สมมติว่า Webhook ของคุณล่มไปสิบนาทีตอนมีลูกค้าจ่ายเงินพอดี เมื่อระบบกลับมา คุณจะได้เหตุการณ์ที่มาช้า บางตัวมาซ้ำ และบางตัวมาสลับลำดับ ถ้า handler ไม่ได้ออกแบบมารองรับ ออเดอร์อาจถูกอนุมัติสองครั้งหรือค้างอยู่ตลอดไป บทความนี้อธิบายกลไกการส่งซ้ำของ PPay ตามคู่มือ และวิธีเขียนฝั่งรับให้ทนทาน
เข้าใจการส่งแบบ at-least-once
คู่มือระบุว่าระบบรับประกัน "ส่งอย่างน้อยหนึ่งครั้ง" ความหมายคือ ถ้าคุณตอบ 2xx ช้าเกินกำหนดหรือเครือข่ายหลุด อาจได้รับเหตุการณ์เดิมซ้ำ นี่ไม่ใช่ข้อบกพร่อง แต่เป็นการแลกเปลี่ยนที่ตั้งใจ เพราะระบบเลือกว่าส่งซ้ำดีกว่าทำให้เหตุการณ์หายไป หน้าที่ของฝั่งรับคือทำให้ "รับกี่ครั้งก็ได้ผลลัพธ์เท่ากัน"
ตารางการส่งซ้ำ
| ครั้งที่ล้มเหลว | ส่งซ้ำอีกครั้งใน |
|---|---|
| 1 | 1 นาที |
| 2 | 5 นาที |
| 3 | 30 นาที |
| 4 | 2 ชั่วโมง |
| 5 | 6 ชั่วโมง |
| 6 | เลิกส่งอัตโนมัติ (สถานะล้มเหลว) กดส่งซ้ำเองได้ในหน้า API สำหรับนักพัฒนา |
จากตารางนี้ หากเซิร์ฟเวอร์คุณล่มนานกว่าประมาณ 8 ชั่วโมงกว่า ๆ นับรวมช่วงรอ เหตุการณ์ที่ล้มเหลวทุกครั้งจะถูกหยุดส่ง ดังนั้นหลังกู้ระบบ ควรมีขั้นตอน "กู้คืน" ไม่ใช่รอเฉย ๆ คือกดส่งซ้ำจากหน้าจัดการ หรือดึงสถานะของงานที่ค้างด้วย GET /api/v1/jobs/{jobId} (ผลงานคิวเก็บ 30 วัน ส่วนประวัติ Webhook เก็บ 14 วัน)
เงื่อนไขที่นับว่าส่งล้มเหลวคือไม่ได้ 2xx ภายใน 8 วินาที รวมถึง 3xx เพราะระบบไม่ตามการ redirect ดังนั้นตรวจให้แน่ใจว่า URL ที่ลงทะเบียนไม่ถูก redirect เช่นจาก http ไป https หรือเพิ่มเครื่องหมายทับท้าย
หลักการ idempotent handler
ใช้ฟิลด์ id ของเหตุการณ์ (หรือ data.jobId) เป็นกุญแจกันซ้ำ แนวทางที่ปลอดภัยที่สุดคือใช้ฐานข้อมูลที่มี unique constraint
// pseudo-code
app.post("/webhooks/ppay", express.raw({ type: "application/json" }), async (req, res) => {
// 1) verify signature first (see the signature article)
const event = JSON.parse(req.body.toString("utf8"));
// 2) record the event id; the unique constraint rejects duplicates
const first = await db.insertIgnore("webhook_events", { id: event.id });
// 3) acknowledge quickly
res.sendStatus(200);
// 4) do the work only the first time
if (first) await queue.push(event);
});
ข้อสังเกตคือ ตอบ 200 ทั้งกรณีใหม่และกรณีซ้ำ เพราะถ้าตอบ error กับเหตุการณ์ซ้ำ ระบบจะส่งซ้ำไปเรื่อย ๆ ส่วนงานจริงให้ใส่คิวภายในของคุณ เพื่อให้ตอบกลับทันก่อน 8 วินาที
ตอบเร็วแล้วทำงานทีหลัง
อย่าทำงานหนักก่อนตอบ เช่นส่งอีเมล เรียก API ภายนอก หรือพิมพ์ใบเสร็จ ถ้างานเหล่านั้นช้า คุณจะเกิน 8 วินาที ระบบมองว่าล้มเหลวแล้วส่งซ้ำ ทำให้เกิดงานซ้ำซ้อน เขียนเหตุการณ์ลงตารางหรือคิวก่อน ตอบ 200 แล้วให้ worker แยกประมวลผลต่อ ถ้า worker พลาดก็ลองใหม่ได้โดยไม่ต้องพึ่งการส่งซ้ำจาก PPay
เหตุการณ์ที่มาช้าหรือสลับลำดับ
อย่าสมมติว่าเหตุการณ์มาตามลำดับเวลา ให้ออกแบบให้ปลอดภัยแม้ลำดับสลับ
- ใช้
data.referenceที่คุณส่งไปตอน/verify/asyncจับคู่ออเดอร์ ไม่ใช้ลำดับที่มาถึง - ก่อนอนุมัติ ให้ตรวจสถานะปัจจุบันของออเดอร์ ถ้ายกเลิกหรือหมดอายุไปแล้ว ให้ส่งเข้ากระบวนการตรวจด้วยคนหรือกระบวนการคืนเงิน ดูแนวทางที่ ขั้นตอนคืนเงินของร้านออนไลน์
- ถ้าได้เหตุการณ์เก่ามาก ให้ตรวจเวลา
createdAtร่วมกับเวลาของออเดอร์ เพื่อรู้ว่าเป็นของช่วงเวลาใด - สำหรับผลที่ผิดปกติ ใช้
GET /api/v1/jobs/{jobId}ยืนยันสถานะล่าสุดกับต้นทางได้
ตรวจสอบและเฝ้าระวัง
- บันทึก
X-PPay-DeliveryและX-PPay-Attemptลง log เพื่อรู้ว่าเหตุการณ์ใดถูกส่งซ้ำกี่ครั้ง - ตั้งการแจ้งเตือนเมื่ออัตราการตอบที่ไม่ใช่ 2xx สูงผิดปกติ
- มีรายงานรายวันเทียบออเดอร์ที่รอชำระเกินเวลากับสถานะงานฝั่ง PPay ดู การกระทบยอดรายวัน
เช็กลิสต์
- ตรวจลายเซ็นก่อนเสมอ
- กันซ้ำด้วย id ที่มี unique constraint
- ตอบ 2xx ภายใน 8 วินาที งานหนักไปคิว
- URL ไม่ redirect
- มีขั้นตอนกู้คืนหลังระบบล่ม
- ออกแบบโดยไม่พึ่งลำดับของเหตุการณ์
อ่านเรื่องลายเซ็นต่อที่ การตรวจสอบ webhook และดูรายการเหตุการณ์ทั้งหมดใน คู่มือ API