1. Home
  2. Insights
  3. Security
  4. Idempotency Key คืออะไร และทำไม API ที่มี side effect ต้องมี
Security

Idempotency Key คืออะไร และทำไม API ที่มี side effect ต้องมี

อธิบายแนวคิด idempotency key แบบใช้งานจริง พร้อมตัวอย่าง Express/Node.js สำหรับกัน request ซ้ำ, retry ซ้ำ และลดความเสี่ยง state เพี้ยนในระบบ production

Idempotency Key คืออะไร และทำไม API ที่มี side effect ต้องมี

ระบบจำนวนมากเริ่มจาก endpoint ง่าย ๆ เช่น

  • POST /payments
  • POST /orders
  • POST /refunds
  • POST /subscriptions

ในช่วงแรก flow มักดูตรงไปตรงมา รับ request, สร้างข้อมูล, ตอบผลลัพธ์กลับไป แต่พอขึ้น production จริง ระบบจะเริ่มเจอคำถามที่สำคัญมากขึ้นทันที

ถ้า client กดส่งซ้ำเพราะเน็ตค้างจะเกิดอะไรขึ้น
ถ้า mobile app timeout แล้ว retry request เดิมอีกครั้งจะเกิดอะไรขึ้น
ถ้า reverse proxy หรือ job worker ส่งคำขอซ้ำโดยไม่ตั้งใจจะเกิดอะไรขึ้น
ถ้า payment route ถูกเรียกสองรอบติดกัน ระบบจะสร้างรายการซ้ำไหม

ปัญหาเหล่านี้ไม่ได้เกิดจาก attacker อย่างเดียว หลายครั้งมันเกิดจากพฤติกรรมปกติของระบบ distributed เช่น timeout, reconnect, retry, duplicate submit, และ partial failure

ตรงนี้เองที่ Idempotency Key เข้ามามีบทบาท

บทความนี้อธิบายว่า idempotency key คืออะไร ทำไม API ที่มี side effect ควรมี และควรออกแบบอย่างไรให้ใช้งานได้จริงใน production

TL;DR

ถ้าจะจำให้สั้นที่สุด ให้จำประโยคนี้

request เดิม ต้องให้ผลลัพธ์เดิม โดยไม่สร้าง side effect ซ้ำ

Idempotency คืออะไร

ในเชิงระบบ คำว่า idempotent หมายถึงการเรียก operation เดิมซ้ำหลายครั้ง แต่ผลสุดท้ายต่อ state ของระบบยังเหมือนเดิม

ยกตัวอย่างเช่น

  • ถ้า request เดิมตั้งใจสร้าง order หนึ่งรายการ
  • ต่อให้ request เดิมถูกส่งซ้ำ 2 รอบ 3 รอบ หรือ 10 รอบ
  • ระบบก็ควรลงท้ายด้วยการมี order เดิมเพียงรายการเดียว ไม่ใช่สร้างใหม่ทุกครั้ง

สิ่งสำคัญคือ idempotency ไม่ได้แปลว่า response ต้องเหมือนกันทุก byte เสมอไปในทุกระบบ แต่ในงาน API production ส่วนใหญ่เราต้องการให้ request เดิม:

  1. ไม่สร้าง side effect ซ้ำ
  2. ไม่ทำให้ state ภายในเพี้ยน
  3. ให้ผลลัพธ์ที่คาดเดาได้เมื่อมี retry

Idempotency Key คืออะไร

Idempotency Key คือ key ที่ client สร้างขึ้นเพื่อบอก server ว่า

“request นี้คือความพยายามครั้งเดียวกัน ถ้าฉันส่งซ้ำ อย่าทำงานซ้ำ ให้ถือว่าเป็น request เดิม”

โดยทั่วไป client จะส่ง header ลักษณะนี้มาด้วย

Idempotency-Key: 9f5d0a4d-5e91-4f4c-bf86-3a84809f93e1

เมื่อ server ได้ request มาแล้ว server จะใช้ key นี้เพื่อตรวจว่า

  • key นี้เคยถูกใช้หรือยัง
  • request นี้เป็น replay ของคำขอเดิมหรือไม่
  • ควรคืนผลลัพธ์เดิมหรือ reject เพราะ payload ไม่ตรงกับ request เดิม

ทำไม API ที่มี side effect ต้องมี

คำว่า side effect ในที่นี้หมายถึง endpoint ที่ทำให้ state ของระบบเปลี่ยน เช่น

  • สร้าง payment
  • สร้าง order
  • ยิง refund
  • ส่ง email
  • สร้าง invoice
  • เริ่ม workflow
  • ตัด stock
  • เพิ่มสิทธิ์ผู้ใช้

ถ้า endpoint เหล่านี้ไม่มี idempotency protection ความเสี่ยงที่เจอบ่อยมีเช่น

  1. client timeout แล้ว retry ทำให้ charge payment ซ้ำ
  2. user กดปุ่ม submit ซ้ำ ทำให้ order ซ้ำ
  3. mobile reconnect แล้วส่ง request เดิมซ้ำ
  4. worker retry หลัง error แล้ว side effect ถูกทำซ้ำ
  5. proxy หรือ integration layer ส่ง duplicate request โดยไม่ตั้งใจ

สรุปคือปัญหานี้ไม่ใช่แค่เรื่อง UX แต่เป็นเรื่อง correctness ของ state ธุรกิจ

Idempotency Key ต่างจาก validation ทั่วไปอย่างไร

หลายระบบมี validation อยู่แล้ว เช่น

  • field ต้องครบ
  • amount ต้องมากกว่า 0
  • currency ต้องถูก
  • orderId ต้องมี

แต่ validation พวกนี้ไม่ได้ช่วยกัน request ซ้ำ

เพราะ request ซ้ำอาจเป็น request ที่ “ถูกต้องทุกอย่าง” แต่ยังทำให้เกิดผลลัพธ์ซ้ำได้อยู่ดี ดังนั้น validation ปกติกับ idempotency เป็นคนละชั้นกัน

  • Validation ตอบคำถามว่า request นี้ “หน้าตาถูกไหม”
  • Idempotency ตอบคำถามว่า request นี้ “เคยถูกทำไปแล้วหรือยัง”

ลำดับที่ถูกต้องของ API ที่รองรับ idempotency

ลำดับที่ปลอดภัยมักเป็นแบบนี้

  1. รับ request
  2. ตรวจ authentication / authorization
  3. ตรวจว่ามี Idempotency-Key หรือไม่
  4. ตรวจ schema / validate payload
  5. สร้าง fingerprint ของ request
  6. เช็ค key นี้ใน durable storage
  7. ถ้ายังไม่เคยมี ให้จอง key นี้ก่อน
  8. ประมวลผล business logic
  9. บันทึก response และ status ของ key
  10. ถ้า request เดิมเข้ามาอีก ให้คืนผลลัพธ์เดิมแทนการทำซ้ำ

แนวคิดสำคัญคือ server ต้องสามารถแยกให้ออกว่า request ที่เข้ามาใหม่เป็น

  • request ใหม่จริง
  • retry ของ request เดิม
  • หรือ key เดิมแต่ payload คนละชุด

รูปแบบการทำงานที่ควรมี

โดยทั่วไป idempotency จะมีอย่างน้อย 3 กรณีหลัก

1) key ใหม่

ยังไม่เคยมีในระบบ
server รับ request แล้วประมวลผลตามปกติ จากนั้นบันทึกผลลัพธ์ไว้

2) key เดิม + payload เดิม

ถือว่าเป็น retry ของ request เดิม
server ไม่ควรทำ side effect ซ้ำ แต่ควรคืนผลลัพธ์เดิมกลับไป

3) key เดิม + payload คนละชุด

อันนี้ควร reject เพราะแปลว่า client พยายาม reuse key เดิมกับ operation ใหม่ ซึ่งทำให้ semantics พังได้

ตัวอย่าง Express/Node.js แบบเริ่มต้น

ตัวอย่างนี้ตั้งใจให้เห็นโครงหลักของ idempotency key แบบเข้าใจง่ายก่อน โดยใช้ in-memory store เพื่อสาธิตแนวคิด

const express = require("express");
const crypto = require("crypto");

const app = express();
app.use(express.json());

/**
 * demo only:
 * production ควรใช้ database / redis / durable storage
 */
const idempotencyStore = new Map();

app.post("/payments", async (req, res) => {
  try {
    const idempotencyKey = req.get("Idempotency-Key");

    if (!idempotencyKey) {
      return res.status(400).json({
        error: "Missing Idempotency-Key header",
      });
    }

    const payloadFingerprint = hashPayload(req.body);
    const existingRecord = idempotencyStore.get(idempotencyKey);

    if (existingRecord) {
      if (existingRecord.payloadFingerprint !== payloadFingerprint) {
        return res.status(409).json({
          error: "Idempotency key reused with different payload",
        });
      }

      if (existingRecord.status === "completed") {
        return res.status(existingRecord.responseStatus).json(existingRecord.responseBody);
      }

      if (existingRecord.status === "processing") {
        return res.status(409).json({
          error: "Request with this idempotency key is still processing",
        });
      }
    }

    idempotencyStore.set(idempotencyKey, {
      status: "processing",
      payloadFingerprint,
      createdAt: new Date().toISOString(),
    });

    const paymentResult = await createPayment(req.body);

    const responseBody = {
      success: true,
      paymentId: paymentResult.paymentId,
      amount: paymentResult.amount,
      currency: paymentResult.currency,
      status: paymentResult.status,
    };

    idempotencyStore.set(idempotencyKey, {
      status: "completed",
      payloadFingerprint,
      responseStatus: 201,
      responseBody,
      completedAt: new Date().toISOString(),
    });

    return res.status(201).json(responseBody);
  } catch (error) {
    console.error("Payment creation error:", error);
    return res.status(500).json({
      error: "Payment creation failed",
    });
  }
});

function hashPayload(payload) {
  return crypto
    .createHash("sha256")
    .update(JSON.stringify(payload))
    .digest("hex");
}

async function createPayment(payload) {
  validatePaymentPayload(payload);

  /**
   * production จริงควร:
   * - เขียน payment record ลง database
   * - ใช้ transaction
   * - enforce uniqueness ตาม business rule
   * - เขียน audit log
   */
  return {
    paymentId: `pay_${crypto.randomUUID()}`,
    amount: payload.amount,
    currency: payload.currency,
    status: "created",
  };
}

function validatePaymentPayload(payload) {
  if (!payload || typeof payload !== "object") {
    throw new Error("Invalid payload");
  }

  if (typeof payload.amount !== "number" || payload.amount <= 0) {
    throw new Error("Invalid amount");
  }

  if (typeof payload.currency !== "string" || !payload.currency.trim()) {
    throw new Error("Invalid currency");
  }
}

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

โค้ดชุดนี้กำลังป้องกันอะไรอยู่

โค้ดตัวอย่างด้านบนไม่ได้แค่เช็คว่ามี header หรือไม่ แต่กำลังแก้ปัญหาที่พบจริงใน production

1) ป้องกัน duplicate submit

ถ้า user กดปุ่มชำระเงินซ้ำ หรือ app retry request เดิม ระบบจะไม่สร้าง payment ใหม่ทันที เพราะ request ถูกผูกกับ idempotency key เดิม

2) ป้องกัน timeout แล้วสร้างซ้ำ

ในระบบจริง request อาจสำเร็จที่ server แต่ client ไม่ทันได้ response เพราะ timeout ถ้า client retry พร้อม key เดิม server ควรคืนผลเดิมแทนการสร้างใหม่

3) ป้องกัน key reuse แบบผิดความหมาย

ถ้า key เดิมถูกใช้กับ payload คนละชุด แปลว่า request semantics ไม่เหมือนเดิมแล้ว กรณีนี้ควร reject เพื่อกันความกำกวม

4) ป้องกัน side effect ซ้ำ

หัวใจของ idempotency ไม่ใช่แค่คืน response เดิม แต่คือ ไม่ทำ side effect ซ้ำ เช่น

  • ไม่ charge ซ้ำ
  • ไม่สร้าง order ซ้ำ
  • ไม่ refund ซ้ำ
  • ไม่ส่งอีเมลซ้ำโดยไม่จำเป็น

จุดที่ต้องระวังใน production

1) อย่าใช้ in-memory store จริง

Map ใช้สอนได้ดี แต่ production ไม่ควรใช้ เพราะ

  • process restart แล้วข้อมูลหาย
  • ถ้ามีหลาย instance ข้อมูลไม่แชร์กัน
  • scale horizontal แล้ว dedup พังทันที

ทางที่เหมาะกว่าคือ

  • Redis
  • Postgres
  • DynamoDB
  • durable key-value store ที่ทุก instance ใช้ร่วมกันได้

2) อย่าเก็บแค่ key อย่างเดียว

ถ้าเก็บแค่ Idempotency-Key แต่ไม่ผูกกับ payload fingerprint คุณจะไม่รู้เลยว่า request เดิมหรือ request ใหม่ที่ reuse key

ดังนั้นควรเก็บอย่างน้อย:

  • idempotency key
  • request fingerprint
  • status
  • response body หรือ reference ไปยังผลลัพธ์เดิม
  • created_at / completed_at

3) ต้องกัน race condition

สมมติ request เดิมวิ่งเข้ามาพร้อมกัน 2 ตัวในเวลาเกือบเท่ากัน ถ้าระบบไม่มี locking หรือ unique constraint ทั้งสองตัวอาจหลุดไปสร้าง side effect ซ้ำได้

แนวทางที่ใช้จริงมักเป็นแบบนี้

  • unique constraint ที่ระดับ storage
  • transaction
  • row lock
  • distributed lock
  • atomic insert-if-not-exists

4) อย่าคิดว่า idempotency แทน business validation ได้

แม้ request จะมี idempotency key ก็ยังต้อง validate business logic ตามปกติ เช่น

  • user นี้มีสิทธิ์ไหม
  • amount ถูกไหม
  • order state อนุญาตให้จ่ายไหม
  • currency ตรงไหม
  • resource นี้ถูกปิดไปแล้วหรือยัง

idempotency ไม่ได้แทน authorization, validation หรือ workflow guard

5) ต้องกำหนด retention policy

idempotency key ไม่ควรถูกเก็บตลอดไปเสมอ ต้องมีนโยบายชัดว่าเก็บนานแค่ไหน เช่น

  • 24 ชั่วโมง
  • 48 ชั่วโมง
  • 7 วัน

ขึ้นกับลักษณะธุรกิจและ retry pattern ของ client/provider

รูปแบบ persistence ที่ควรใช้จริง

ระบบจริงมักเก็บข้อมูลประมาณนี้

create table idempotency_keys (
  id bigserial primary key,
  idempotency_key text not null,
  request_fingerprint text not null,
  status text not null,
  response_status integer,
  response_body jsonb,
  created_at timestamptz not null default now(),
  completed_at timestamptz,
  unique (idempotency_key)
);

ในบางระบบอาจต้องเพิ่มข้อมูลพวกนี้ด้วย

  • user_id
  • endpoint
  • method
  • tenant_id
  • expires_at

เพราะ key เดียวกันอาจควร unique ภายใน scope บางอย่างเท่านั้น ไม่ใช่ทั้งระบบเสมอไป

แล้วควรคืน status code อะไร

ขึ้นกับ policy ของระบบ แต่โดยทั่วไปแนวคิดจะประมาณนี้

  • 201 Created เมื่อ request ใหม่สำเร็จ
  • 200 OK หรือ 201 Created เมื่อเป็น replay แล้วคืนผลเดิม
  • 409 Conflict เมื่อ key เดิมถูกใช้กับ payload คนละชุด
  • 400 Bad Request เมื่อไม่มี key ทั้งที่ endpoint นี้บังคับให้มี

สิ่งสำคัญไม่ใช่เลข status code เพียงอย่างเดียว แต่คือ behavior ต้องสม่ำเสมอและอธิบายได้

Idempotency กับ payment/webhook ต่างกันอย่างไร

สองอย่างนี้เกี่ยวข้องกันแต่ไม่เหมือนกัน

Idempotency Key

มักใช้กับ request ที่ client ยิงเข้ามาหาเรา เช่น POST /payments

เป้าหมายคือกัน request ซ้ำจากฝั่ง caller

Webhook Deduplication

มักใช้กับ event ที่ provider ยิงเข้ามาหาเรา เช่น payment.succeeded

เป้าหมายคือกัน event delivery ซ้ำจากฝั่ง provider

สรุปคือ

  • idempotency key = ป้องกัน request ซ้ำฝั่ง inbound API
  • event id dedup = ป้องกัน event ซ้ำฝั่ง webhook/event ingestion

ระบบจริงจำนวนมากต้องมีทั้งคู่

รีวิวโค้ดชุดนี้แบบ production-minded

Correctness

โครงหลักถูกต้อง เพราะใช้ key เพื่อตรวจ replay และคืนผลลัพธ์เดิมสำหรับ request เดิม แต่ production จริงควรผูก key กับ scope เช่น user หรือ endpoint ด้วย เพื่อกัน key ชนกันข้ามบริบท

Security

idempotency ไม่ใช่ security control โดยตรงแบบ authentication หรือ signature verification แต่มันช่วยลดความเสียหายจาก retry, replay ภายในบริบทที่ได้รับอนุญาตแล้ว และช่วยให้ state ไม่เพี้ยนจาก duplicate execution

Efficiency

สำหรับ traffic ไม่สูง logic นี้ตรงไปตรงมาและเข้าใจง่าย แต่เมื่อโตขึ้นควรย้ายไปใช้ durable storage, unique constraint, และอาจแยก response replay ออกจาก heavy business processing

Error handling

route นี้ยังตอบ 500 เมื่อพัง ซึ่งโอเคเป็นพื้นฐาน แต่ production ควร log ให้ละเอียดขึ้น เช่น idempotency key, request id, user id, endpoint, failure reason และ latency

Checklist สั้น ๆ ก่อนปล่อย endpoint สำคัญขึ้น production

  • endpoint ที่มี side effect มี Idempotency-Key
  • key ถูกเก็บใน durable storage
  • key ผูกกับ request fingerprint
  • request เดิมคืนผลลัพธ์เดิมได้
  • key เดิมแต่ payload คนละชุดถูก reject
  • มี unique constraint หรือ atomic locking
  • business logic ด้านในไม่สร้าง side effect ซ้ำ
  • มี retention policy สำหรับ key
  • มี structured logging และ audit trail

สรุป

ระบบ production ไม่ควรตีความ request ซ้ำเป็นเรื่องแปลก เพราะในโลกจริง timeout, retry, reconnect, duplicate submit และ partial failure เกิดขึ้นเป็นปกติ

Idempotency Key จึงไม่ใช่แค่ของเสริม แต่เป็นกลไกสำคัญที่ช่วยให้ API ที่มี side effect ทำงานได้อย่างคาดเดาได้ ปลอดภัยต่อ state ธุรกิจ และทนต่อความไม่แน่นอนของระบบ distributed มากขึ้น

สรุปสั้นที่สุดอีกครั้ง

request เดิม ต้องไม่สร้างผลกระทบซ้ำ

💬 Chat (ตอบเร็ว)