Idempotency
Idempotency-Key เป็น header บังคับของทุก POST ไม่ใช่ตัวเลือก · ไม่ส่ง = 400 idempotency_key_required ตั้งแต่ก่อนแตะ business logic
กฎข้อเดียวที่ต้องจำ
เขียน key ลงฐานข้อมูลของคุณให้สำเร็จก่อน แล้วค่อยยิง request · การ retry ทุกครั้งต้อง อ่าน key เดิมจากฐานข้อมูล ไม่ใช่ gen ใหม่
merchant ที่ gen key ใหม่ตอน retry จะได้ออร์เดอร์ซ้ำ QR สองใบ และฝั่งถอนคือล็อกเงินสองรอบ
ข้อกำหนดพื้นฐาน
| เรื่อง | ค่า |
|---|---|
| endpoint ที่บังคับ | ทุก POST |
| อายุของ key (TTL ฝั่งเรา) | 7 วัน |
| TTL ฝั่งคุณ | 🔴 ต้องไม่สั้นกว่า 7 วัน |
| ที่เก็บ | ฐานข้อมูลที่ consistent ทันที (เราใช้ Postgres) 🔴 อย่าเก็บใน KV ที่ eventual |
| ความยาว/รูปแบบ | สตริงที่คุณกำหนดเอง ต้องไม่ซ้ำข้าม logical operation |
ทำไม TTL ถึงเป็น 7 วัน — หน้าต่าง retry ของ webhook ยาว ~92 ชั่วโมง 36 นาที (ไม่ถึง 4 วัน) เราตั้ง TTL ให้ยาวกว่าโดยตั้งใจ เพื่อให้ retry ครั้งสุดท้ายยังชนกับ idempotency record ฝั่งคุณได้เสมอ · ถ้าคุณตั้ง TTL สั้นกว่า 7 วัน จะมีช่องเวลาที่ระบบสองฝั่งเห็นความจริงคนละแบบ
สี่เคสที่ต้องเขียนโค้ดรองรับ
| สถานการณ์ | เราตอบอะไร |
|---|---|
| key เดิม + body เหมือนเดิม + request แรกจบแล้ว | คืน response เดิมทั้งดุ้น (รวม HTTP status เดิม) + header Idempotent-Replay: true |
| key เดิม + body ต่าง | 409 idempotency_key_reused + details.original_request_id |
| key เดิม + request แรก ยังไม่จบ | 409 idempotency_in_flight + retry_after_seconds: 1 — 🔴 เราไม่ block ไม่รอให้ |
| key หมดอายุ (> 7 วัน) แล้วยิงซ้ำ | ทำงานเหมือน request ใหม่ ⇒ สร้างออร์เดอร์ใหม่จริง ๆ |
ข้อยกเว้นของ /slip: เทียบ logical evidence ไม่เทียบ raw body
ตารางนี้ใช้ raw body กับ merchant API ปกติ แต่ POST slip_upload.upload_url เทียบ versioned evidence_fingerprint จาก decoded image + canonical fields: multipart boundary, ลำดับ JSON key หรือรูปแบบ base64 ต่างกันแต่หลักฐานเดิมยังเป็น replay; key เดิมจะได้ 409 เฉพาะเมื่อ logical evidence ต่างจริง
เคสที่ 1 — key เดิม body เดิม request แรกจบแล้ว
นี่คือเคสที่คุณ อยาก ให้เกิดตอน retry
# ยิงครั้งที่ 1 แล้ว network timeout ไปก่อนได้ response
curl -sS -X POST "$PG_API/v1/pay/deposits" \
-H "Idempotency-Key: ORD-9981-topup" ... --data-binary @body.json
# ยิงซ้ำด้วย key เดิม body เดิม
curl -sSi -X POST "$PG_API/v1/pay/deposits" \
-H "Idempotency-Key: ORD-9981-topup" ... --data-binary @body.jsonHTTP/1.1 201 Created
Idempotent-Replay: true
Content-Type: application/json{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 2,
"status": "awaiting_payment",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"expires_at": "2026-08-21T04:30:47.902Z",
"checkout_url": "https://pay.example-wl.com/c/9f2c1ab6e0d74f",
"slip_upload": {
"token": "pgsut_eyJraWQiOiJvcmRlc...",
"expires_at": "2026-08-22T04:30:47.902Z",
"upload_url": "https://slip-api.example.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/slip",
"max_bytes": 4194304,
"content_types": ["image/jpeg", "image/png", "image/webp", "application/json"]
}
}ได้ deposit_id เดิม ไม่มีออร์เดอร์ใหม่เกิดขึ้น
response ที่ replay รวม capability snapshot เดิมด้วย ถือเป็น bearer secret: ห้าม log และต้องใช้ upload_url, max_bytes, content_types ที่ replay คืนมาโดยตรง ห้ามประกอบ upload host เอง
response ที่ replay คือภาพถ่ายของอดีต ไม่ใช่สถานะปัจจุบัน
ถ้า request แรกตอบ 202 awaiting_liquidity การ replay จะคืน 202 เดิมพร้อม legs: [] เดิม แม้ตอนนี้ออร์เดอร์จะเดินไปถึง awaiting_payment และมี QR แล้วก็ตาม
⇒ อ่านสถานะจริงจาก GET /v1/pay/deposits/{id} หรือรอ webhook เท่านั้น ห้ามอ่านจาก body ของ replay
เช็คได้จากโค้ด
const res = await fetch(url, { method: 'POST', headers, body: rawBody });
const isReplay = res.headers.get('Idempotent-Replay') === 'true';
const order = await res.json();
if (isReplay) {
// อย่าเชื่อ order.status จากตรงนี้ — ไปอ่านของจริง
return await getDeposit(order.id);
}
return order;เคสที่ 2 — key เดิม body ต่าง
HTTP/1.1 409 Conflict{
"error": {
"code": "idempotency_key_reused",
"type": "invalid_request_error",
"message": "This Idempotency-Key was already used with a different request body.",
"message_th": "Idempotency-Key นี้ถูกใช้ไปแล้วกับ body คนละชุด",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01K3M8QW7Z4N2VYB6H0RSTA9CD",
"details": { "original_request_id": "req_01K3M7YA5B2CDEFGH0JKLMN4PQ" }
}
}นี่ไม่ใช่บั๊กของเรา เป็นสัญญาณว่าโค้ดคุณมีบั๊ก — ปกติเกิดจากสองสาเหตุ
- ผู้ใช้กดแก้ยอดจาก 5,000 เป็น 3,000 แล้วกดใหม่ แต่โค้ดยัง reuse key เดิมที่ผูกกับ session
- โค้ดเติม field ที่เปลี่ยนค่าทุกครั้งลงใน body เช่น
requested_atหรือclient.ipที่เปลี่ยนตอน retry ผ่าน mobile network
client.ip เปลี่ยนได้ระหว่าง retry
ถ้าคุณอ่าน IP สดตอนยิงทุกครั้ง body จะไม่เหมือนเดิม ⇒ retry ได้ 409 idempotency_key_reused แทนที่จะได้ replay วิธีที่ถูก: snapshot client{} ทั้งก้อนตอนสร้าง key แล้วเก็บลง DB คู่กับ key · retry ส่ง body ตัวเดิมจาก DB
กฎ: เปลี่ยน body = ต้องเปลี่ยน key · จบ
เคสที่ 3 — key เดิม แต่ request แรกยังไม่จบ
เกิดจากผู้ใช้ดับเบิลคลิก หรือโค้ด retry ที่ยิงซ้ำทันทีเมื่อ client-side timeout ทั้งที่ฝั่งเรายังประมวลผลอยู่
HTTP/1.1 409 Conflict{
"error": {
"code": "idempotency_in_flight",
"type": "invalid_request_error",
"message": "A request with this Idempotency-Key is still being processed.",
"message_th": "request ที่ใช้ Idempotency-Key นี้ยังประมวลผลไม่เสร็จ",
"retryable": true,
"retry_after_seconds": 1,
"request_id": "req_01K3M8QX8Z4N2VYB6H0RSTA9CE"
}
}เราไม่ block ให้ และนั่นคือสิ่งที่ถูก
gateway บางเจ้าจะ "รอ" ให้ request แรกจบแล้วค่อยตอบ ซึ่งทำให้ connection ค้างและ thread pool ฝั่งคุณตันเมื่อระบบตึง · เราตอบ 409 ทันทีพร้อมบอกว่าให้รอกี่วินาที
วิธี handle ที่ถูก
async function postWithIdempotency(url, rawBody, headersFor, key, { maxAttempts = 5 } = {}) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// 🔴 nonce ใหม่ทุก attempt · key เดิมตลอด
const res = await fetch(url, { method: 'POST', headers: headersFor(rawBody, key), body: rawBody });
if (res.status !== 409) return res; // รวม 201 / 202 / error อื่น
const body = await res.json();
if (body.error?.code === 'idempotency_in_flight') {
const wait = (body.error.retry_after_seconds ?? 1) * 1000;
await new Promise((r) => setTimeout(r, wait + Math.random() * 250)); // jitter
continue;
}
// 409 อื่น เช่น idempotency_key_reused / duplicate_open_deposit — เป็นบั๊กของเรา ไม่ใช่เรื่องรอ
return res;
}
// ครบรอบแล้วยังไม่จบ — ห้ามสร้าง key ใหม่ ให้ไปถามสถานะจริงแทน
throw new Error('idempotency_in_flight_exhausted');
}ห้ามแก้ปัญหานี้ด้วยการ gen key ใหม่
in_flight แปลว่า request แรก อาจสำเร็จอยู่ · gen key ใหม่ = ออร์เดอร์ซ้ำ · ถ้ารอครบรอบแล้วยังไม่ได้คำตอบ ให้ค้นด้วย merchant_reference ผ่าน GET /v1/pay/deposits?... แทน
เคสที่ 4 — key หมดอายุแล้วยิงซ้ำ
หลัง 7 วัน record ฝั่งเราหายไป ระบบจะมองว่าเป็น request ใหม่ทั้งหมด ⇒ สร้างออร์เดอร์ใหม่จริง ๆ ตอบ 201/202 ตามปกติ ไม่มี Idempotent-Replay
HTTP/1.1 201 Created{
"id": "dep_01K4RB2QX8ZM5TN0WHE7KJ4C7D",
"object": "deposit_order",
"version": 2,
"status": "awaiting_payment",
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"currency": "THB",
"created_at": "2026-08-28T09:14:02.117Z",
"expires_at": "2026-08-28T09:34:02.117Z",
"slip_upload": {
"token": "pgsut_eyJraWQiOiJuZXctb3JkZXI...",
"expires_at": "2026-08-29T09:34:02.117Z",
"upload_url": "https://slip-api.example.com/v1/pay/deposits/dep_01K4RB2QX8ZM5TN0WHE7KJ4C7D/slip",
"max_bytes": 4194304,
"content_types": ["image/jpeg", "image/png", "image/webp", "application/json"]
}
}สังเกตว่า merchant_reference เดิม แต่ id เป็นคนละใบ
นี่คือเหตุผลที่ TTL ฝั่งคุณต้องไม่สั้นกว่า 7 วัน
job ที่ retry งานค้างแบบไม่จำกัดอายุ (เช่น dead-letter queue ที่ไล่ใหม่ทุกสัปดาห์) จะสร้างออร์เดอร์ซ้ำโดยไม่มีอะไรมากั้น
ต้องมีสองชั้น: ชั้นที่ 1 UNIQUE(merchant_reference) ในฐานข้อมูลของคุณ · ชั้นที่ 2 อย่า retry งานที่เก่ากว่า 7 วัน ให้ไปเข้ากระบวนการ reconcile แทน
วิธีเลือกค่า key
หลักการ: key เป็นฟังก์ชันของ เนื้อหา ไม่ใช่ของ session หรือ attempt
| ✅ ใช้ได้ | ❌ ห้ามใช้ | เพราะ |
|---|---|---|
dep-ORD-9981 (ผูกกับ merchant_reference) | crypto.randomUUID() ตอนยิง | สุ่มใหม่ทุก attempt = ออร์เดอร์ซ้ำ |
dep-ORD-9981-r2 เมื่อผู้ใช้แก้ยอดแล้วสร้างใหม่ | session_id ของผู้ใช้ | ผู้ใช้คนเดียวสร้างหลายรายการใน session เดียว |
sha256(merchant_reference + amount_minor + sender_id) | end_user_ref เพียว ๆ | ผู้ใช้เติมเงินหลายครั้ง |
wdr-<withdrawal_ref> | timestamp | เปลี่ยนทุกวินาที |
สูตรที่แนะนำ
import crypto from 'node:crypto';
// key ผูกกับ "เจตนา" ของรายการ — ผู้ใช้แก้ยอดหรือเปลี่ยนบัญชีผู้โอน = เจตนาใหม่ = key ใหม่
function idempotencyKeyFor({ merchantReference, amountMinor, senderId, attemptOfIntent = 1 }) {
const material = `${merchantReference}|${amountMinor}|${senderId}|${attemptOfIntent}`;
return 'dep-' + crypto.createHash('sha256').update(material).digest('hex').slice(0, 32);
}attemptOfIntent เพิ่มขึ้นเมื่อผู้ใช้ ตั้งใจ สร้างรายการใหม่ (กดยกเลิกแล้วสร้างใหม่ / เปลี่ยนบัญชีผู้โอน) ไม่ใช่เมื่อ network พัง
ลำดับที่ถูกต้องในโค้ด
-- 1) เขียนก่อนยิง — ในทรานแซกชันของคุณเอง
INSERT INTO pg_requests (merchant_reference, idempotency_key, request_body, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (merchant_reference) DO NOTHING
RETURNING idempotency_key, request_body;
-- rowCount = 0 ⇒ มีคนสร้างไปแล้ว → SELECT ค่าเดิมมาใช้ ห้าม gen ใหม่
-- 2) ยิง request ด้วย idempotency_key และ request_body ที่อ่านมาจากแถวนี้
-- 3) ได้ 2xx แล้วค่อยอัปเดต
UPDATE pg_requests
SET status = 'done', deposit_id = $2, http_status = $3, responded_at = now()
WHERE merchant_reference = $1;CREATE TABLE pg_requests (
merchant_reference text PRIMARY KEY, -- ชั้นกันซ้ำชั้นที่ 1
idempotency_key text NOT NULL UNIQUE, -- ชั้นกันซ้ำชั้นที่ 2
request_body jsonb NOT NULL, -- 🔴 body ตัวเดิมที่ใช้ retry
deposit_id text,
http_status int,
status text NOT NULL, -- pending | done | failed
created_at timestamptz NOT NULL DEFAULT now(),
responded_at timestamptz
);
-- 🔴 ห้ามลบแถวก่อน 7 วัน
CREATE INDEX ON pg_requests (created_at);<?php
// PHP — โครงเดียวกัน
$pdo->beginTransaction();
$st = $pdo->prepare(
'INSERT INTO pg_requests (merchant_reference, idempotency_key, request_body, status)
VALUES (:ref, :key, :body, \'pending\')
ON CONFLICT (merchant_reference) DO NOTHING'
);
$st->execute([':ref' => $ref, ':key' => $key, ':body' => $rawBody]);
$row = $pdo->prepare('SELECT idempotency_key, request_body FROM pg_requests WHERE merchant_reference = :ref');
$row->execute([':ref' => $ref]);
$saved = $row->fetch(PDO::FETCH_ASSOC); // 🔴 ใช้ค่าจากแถวนี้เสมอ ไม่ใช่ตัวแปรในหน่วยความจำ
$pdo->commit();
$res = pg_post('/v1/pay/deposits', json_decode($saved['request_body'], true), $saved['idempotency_key']);เรื่องที่คนมักเข้าใจผิด
429 ไม่ยึด Idempotency-Key
โดน 429 rate_limited แล้วยิงซ้ำด้วย key เดิมได้ทันทีหลังพ้น Retry-After · key ไม่ถูก "ใช้ไป" เพราะ request ไม่เคยถึง business logic
Idempotency-Key อยู่ใน canonical string ของลายเซ็น
เปลี่ยนค่า key แล้วลายเซ็นต้องเปลี่ยนตาม · ลืมใส่บรรทัดนี้ตอนเซ็น = 401 signature_invalid ไม่ใช่ 400 — ดู authentication
PG-Nonce กับ Idempotency-Key ทำหน้าที่ตรงข้ามกัน
| เปลี่ยนเมื่อไหร่ | ถ้าทำผิด | |
|---|---|---|
PG-Nonce | ทุก attempt | 401 nonce_replayed |
Idempotency-Key | เฉพาะเมื่อเจตนาเปลี่ยน | ออร์เดอร์ซ้ำ |
อย่าเก็บ idempotency record ไว้ใน cache ที่ eventual consistent
ระบบ KV ที่กระจายทั่วโลกใช้เวลา sync ได้ถึง ~60 วินาที · request ที่สองอาจอ่านไม่เจอแถวที่ request แรกเพิ่งเขียน แล้วสร้างออร์เดอร์ใหม่ · เก็บในฐานข้อมูลที่อ่านเห็นทันทีเท่านั้น
เช็กลิสต์
- [ ] ทุก
POSTมีIdempotency-Key - [ ] key ถูกเขียนลง DB ก่อน ยิง request และมี
UNIQUE - [ ] retry อ่าน key และ body ตัวเดิมจาก DB ไม่ gen ใหม่
- [ ] TTL ฝั่งคุณ ≥ 7 วัน
- [ ] handle
409 idempotency_in_flightด้วยการรอตามretry_after_seconds+ jitter - [ ] handle
409 idempotency_key_reusedเป็น alert ไม่ใช่ retry - [ ] ยิง
POST /v1/pay/depositsซ้ำด้วย key เดิม แล้วได้deposit_idเดิม (ทดสอบจริงบน sandbox) - [ ] ทดสอบยอด 115.00 บน sandbox เพื่อดูเส้นทาง
idempotency_key_reused
ขั้นต่อไป
- ทดสอบเคส
409จริงบน sandbox → Sandbox Idempotency-Keyอยู่ในลายเซ็นอย่างไร → การยืนยันตัวตนของ request- ยิง
POSTใบแรก → Quickstart - error code ทั้งหมด → ตาราง error
