รับ Event จากอุปกรณ์ที่คุมไม่ได้: ทำไม ingest endpoint ต้อง "ตอบสำเร็จเสมอ" และแยกรับออกจากประมวลผล
อุปกรณ์กล้อง IoT และ webhook provider จะตีความ HTTP status ที่ไม่ใช่ 2xx ว่า "ต่อไม่ได้" แล้ว retry วนจนถล่มระบบ บทความนี้อธิบายวิธีคิดที่ถูกต้อง คือแยก "การรับ" ออกจาก "การประมวลผล" พร้อม idempotency กัน delivery ซ้ำ
อ่าน ~8 นาที
บทเรียนราคาแพง: 400 Bad Request หนึ่งครั้ง กลายเป็นพันครั้ง
ผมเคยทำระบบที่รับ event จากอุปกรณ์ปลายทางจำนวนมาก ทั้งกล้องสแกนใบหน้า อุปกรณ์ IoT และ webhook จาก payment provider ภายนอก สิ่งที่ทุกตัวมีร่วมกันคือ ผมคุมมันไม่ได้ ผมแก้ firmware ของกล้องไม่ได้ ผมแก้ retry policy ของ provider ไม่ได้ ผมได้แค่ endpoint ปลายทางฝั่งผมเท่านั้น
วันหนึ่ง endpoint รับ event ของผมเจอ payload ที่ validate ไม่ผ่าน ตามสัญชาตญาณ dev ทั่วไป ผมตอบ 400 Bad Request กลับไป — ซึ่งดู "ถูกต้อง" ตามหลัก REST ทุกประการ ปัญหาคืออุปกรณ์ปลายทางไม่ได้อ่าน body ที่ผมอธิบายว่า "payload ผิดตรงไหน" มันดูแค่ status code แล้วสรุปว่า "ส่งไม่สำเร็จ = ปลายทางมีปัญหา = ต้องส่งใหม่" แล้วมันก็ส่งใหม่ ทุก 5 วินาที ไม่มีวันหยุด
event เดียวที่ผิด กลายเป็น retry storm ที่ยิงเข้ามาไม่หยุด กิน connection pool กิน log กิน CPU ของ validation ซ้ำๆ กับ payload ที่ยังไงก็ไม่มีวันผ่าน นี่คือจุดที่ผมเข้าใจหลักการสำคัญข้อหนึ่ง
Thesis: producer ที่คุมไม่ได้จะลงโทษคุณ ถ้า HTTP status สะท้อน business logic
หัวใจของปัญหาคือ ผมกำลังใช้ HTTP status code สื่อสารสองเรื่องพร้อมกัน ทั้ง "ผมรับข้อมูลได้ไหม" (transport) และ "ข้อมูลถูกต้องตาม business logic ไหม" (application) แต่ producer ที่ผมคุมไม่ได้ ตีความ status ทั้งหมดเป็นเรื่องเดียว คือ transport
สำหรับกล้องหรือ provider คำว่า non-2xx แปลว่า "ปลายทางยังไม่ได้รับ ให้ส่งใหม่" มันไม่สนใจว่า 400 หมายถึง payload ผิด, 422 หมายถึง validation fail หรือ 500 หมายถึง server พัง สำหรับมันทั้งหมดคือ "ยังไม่สำเร็จ retry ต่อ" ยิ่ง provider ที่ทำ webhook ดีๆ ยิ่งมี retry policy ที่ก้าวร้าว เพราะเป้าหมายของมันคือ "ต้อง deliver ให้ได้"
บทเรียนคือ เมื่อคุณคุม producer ไม่ได้ คุณต้องยอมรับว่า HTTP status ไม่ใช่ช่องทางรายงานผล business logic อีกต่อไป มันเหลือความหมายเดียวที่ปลอดภัยคือ "ผมได้รับ bytes ของคุณแล้ว"
Pattern หลัก: แยก "การรับ" ออกจาก "การประมวลผล"
วิธีคิดที่ถูกต้องคือแบ่งงานเป็นสองเฟสที่ไม่ปนกัน
- เฟสรับ (ingest): ทำแค่ validate โครงสร้างพื้นฐาน (มี field ที่จำเป็นไหม parse JSON ได้ไหม) แล้ว push เข้า durable queue จากนั้นตอบ
200หรือ202กลับทันที เร็วที่สุดเท่าที่ทำได้ - เฟสประมวลผล (process): business logic ทั้งหมด — การ validate เชิงลึก, การ forward, การ dedup, การ resend — ยกไปทำ async ฝั่ง worker ที่ดึงงานจาก queue
durable queue คือหัวใจ ผมใช้ BullMQ บน Redis แต่หลักการเดียวกันใช้ได้กับ SQS, RabbitMQ หรือ Kafka — ขอแค่ queue ที่ persist งานได้ ไม่หายเมื่อ process ตาย โค้ด ingest ที่ดีควรผอมแบบนี้
// ingest endpoint — ทำน้อยที่สุด ตอบเร็วที่สุด
app.post('/ingest/device-event', async (req, res) => {
const raw = req.body;
// 1) validate โครงสร้างขั้นต่ำเท่านั้น — พอให้ enqueue ได้
if (!raw?.deviceId || !raw?.eventId) {
// ผิดโครงสร้างจริงๆ ก็ตอบ 200 อยู่ดี เพื่อตัด retry storm
// แต่ log ไว้ว่า drop เพราะอะไร
console.warn(`[ingest] drop malformed payload: ${JSON.stringify(raw).slice(0, 200)}`);
return res.status(200).json({ received: true, accepted: false });
}
// 2) push เข้า durable queue — งานจริงเกิดที่ worker
await eventQueue.add('device-event', raw, {
jobId: raw.eventId, // ใช้เป็น idempotency key (ดูหัวข้อถัดไป)
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 1000,
});
// 3) ตอบสำเร็จทันที — producer เห็น 2xx ก็หยุด retry
return res.status(202).json({ received: true });
});
สังเกตว่าแม้ payload จะ malformed ผมก็ยังตอบ 200 เพราะการตอบ error กลับไป ไม่ได้ช่วยให้อุปกรณ์แก้ payload ได้ มันแค่ทำให้มัน retry สิ่งที่ผิดเดิมซ้ำ การ drop เงียบๆ พร้อม log ที่ดี ปลอดภัยกว่าการเปิดประตูให้ retry storm
ราคาที่ต้องจ่าย: debug ยากขึ้น เพราะ error ไม่ย้อนกลับ client
pattern นี้ไม่ฟรี ราคาที่คุณจ่ายคือ เมื่อ error เกิดฝั่ง worker มันไม่ย้อนกลับไปหา client อีกแล้ว อุปกรณ์เห็นแค่ 202 แล้วจากไป มันไม่รู้เลยว่า event ของมันจะ process สำเร็จหรือพังกลางทาง คุณเสีย feedback loop ที่ตรงไปตรงมาที่สุดของ HTTP ไป
สิ่งที่ผมใช้ชดเชยคือ structured log ที่มี prefix คงที่ grep เจอง่าย ทุกขั้นตอนของ lifecycle มี tag ที่ตามรอยได้ ตั้งแต่ ingest จนถึง process จนถึง forward
// ตามรอย lifecycle ของ event เดียวได้ตลอดเส้นทาง ด้วย correlation id
console.log(`[event ${eventId}] ingested from ${deviceId}`);
console.log(`[event ${eventId}] worker picked up, attempt ${job.attemptsMade + 1}`);
console.log(`[event ${eventId}] validation failed: ${reason}`);
console.log(`[event ${eventId}] forwarded ok in ${ms}ms`);
// เวลา debug: grep "[event abc-123]" ก็เห็นทั้งเส้นทางของ event นั้น
กฎที่ผมยึดคือ prefix ต้อง คงที่และ machine-greppable อย่าใช้ข้อความอิสระที่เปลี่ยนไปมา เพราะ log คือ feedback loop ตัวเดียวที่คุณเหลืออยู่ เมื่อ HTTP status ใช้ไม่ได้แล้ว
Webhook ที่ delivered หลายรอบเสมอ: idempotency ไม่ใช่ทางเลือก
เมื่อคุณตอบ 2xx เร็วและ retry ยังมีอยู่ในระบบ (ทั้งของ producer และของ queue คุณเอง) ความจริงที่ต้องยอมรับคือ event เดียวจะถูก deliver หลายรอบแน่นอน ไม่ใช่ "อาจจะ" แต่ "แน่นอน" เช่น producer timeout หลังคุณ enqueue แต่ก่อนตอบเสร็จ มันก็ส่งซ้ำ หรือ worker crash หลังทำงานแต่ก่อน ack, queue ก็ส่งงานเดิมให้ worker ตัวใหม่
ทางแก้คือ ทำให้การ process เป็น idempotent — replay ต้องเป็น no-op ไม่ใช่ double-charge สองกลไกที่ผมใช้คู่กัน
- status-check ก่อนทำงาน: ก่อนประมวลผล เช็คว่า event นี้ทำไปแล้วหรือยัง ถ้าอยู่ในสถานะ terminal แล้ว ให้ข้าม
- unique natural key ที่ระดับ DB: ใส่ unique index บน key ที่มาจากตัว event เอง (เช่น providerTxnId) ให้ DB เป็นด่านสุดท้ายที่กัน insert ซ้ำ ต่อให้ logic พลาด
// worker: idempotent processing
async function processEvent(job) {
const { eventId, providerTxnId } = job.data;
// ด่านที่ 1: status-check — เคยจบแล้วหรือยัง
const existing = await db.events.findOne({ providerTxnId });
if (existing?.status === 'processed') {
console.log(`[event ${eventId}] replay ignored — already processed`);
return; // no-op ปลอดภัย
}
try {
// ด่านที่ 2: unique key ที่ DB — กัน race condition ตอน insert
await db.events.insertOne({
providerTxnId, // มี unique index (+sparse ถ้า field นี้ optional)
eventId,
status: 'processing',
createdAt: new Date(),
});
} catch (err) {
if (err.code === 11000 /* duplicate key */) {
console.log(`[event ${eventId}] duplicate delivery blocked at DB layer`);
return; // อีก worker ทำอยู่/ทำไปแล้ว
}
throw err;
}
await doBusinessLogic(job.data);
await db.events.updateOne({ providerTxnId }, { $set: { status: 'processed' } });
}
จุดที่คนพลาดบ่อยคือ ถ้า natural key เป็น field ที่บาง event ไม่มี อย่าลืมทำ index เป็น unique + sparse ไม่งั้น document ที่ค่า null หลายตัวจะชนกันเองที่ unique constraint ทั้งที่มันคนละ event
Fast-ack แล้วยอมรับความจริง: รายงานผลผ่าน HTTP ไม่ได้อีกแล้ว
เมื่อคุณเลือก fast-ack คุณต้องยอมรับผลตามมาให้ครบ ไม่ใช่แค่ครึ่งเดียว สามเสาที่ต้องมีคู่กันเสมอ
- idempotency — เพราะ delivery ซ้ำเป็นเรื่องปกติ ไม่ใช่ข้อยกเว้น
- durable audit record — ทุก event ต้องมีร่องรอยถาวรใน DB ว่าเข้ามาเมื่อไร สถานะอะไร เพราะคุณตอบ client ไปแล้วว่า "รับแล้ว" คุณจึงมีหน้าที่รับผิดชอบมันจนจบ ต่อให้ process พังคุณต้องตามเก็บได้
- outbound retry แบบ exponential backoff — ตอน worker ต้อง forward event ต่อไป upstream อื่น ถ้ามันล้มเหลว retry ต้องถ่างเวลาออกเรื่อยๆ (2s, 4s, 8s, 16s...) ไม่ใช่กระหน่ำถี่ๆ เพราะคุณกำลังจะกลายเป็น producer ที่ก้าวร้าวใส่คนอื่นเสียเอง
พูดอีกแบบ: คุณย้าย error handling ทั้งหมดจาก "ตอบกลับ synchronous" ไปเป็น "จัดการ asynchronous ฝั่ง worker" และ worker ต้องมีเครื่องมือครบเพื่อรับผิดชอบภาระนั้นแทน HTTP status ที่คุณสละไป
สรุป
- แยก "การรับ" ออกจาก "การประมวลผล" เสมอ — ingest = validate ขั้นต่ำ + enqueue + ตอบ 2xx ทันที ส่วน business logic ยกไป async
- เมื่อคุม producer ไม่ได้ HTTP status เหลือความหมายเดียวที่ปลอดภัยคือ "ได้รับแล้ว" — อย่าใช้ non-2xx สื่อ business error ให้อุปกรณ์ที่ตีความมันเป็น "retry เถอะ"
- non-2xx = คำเชิญให้เกิด retry storm — payload ที่ผิดถาวร ตอบ error ไปก็ไม่มีวันหาย มีแต่ถูกส่งซ้ำ
- delivery ซ้ำเป็นเรื่องแน่นอน ไม่ใช่ข้อยกเว้น — ทำ idempotent ด้วย status-check + unique natural key (unique+sparse ถ้า optional) ให้ replay เป็น no-op
- fast-ack มีราคา — ชดเชย feedback loop ที่หายไปด้วย structured log prefix ที่ grep เจอ + durable audit record + outbound retry แบบ exponential backoff
- signal "ได้รับแล้ว" ต้องแยกจาก "ประมวลผลเสร็จ" เสมอ เมื่อ producer ตีความ status code เอง




