Bỏ qua đến nội dung chính
idempotencypaymentAPI designbackendrace condition

Idempotency Keys: Cách Tránh Double Charge Trong Payment API

Idempotency keys là gì và cách implement đúng để tránh double charge: client retry, network timeout, race condition. Code Node.js + PostgreSQL.

Xuất bản 8 phút đọc

Idempotency keys là chi tiết phân biệt API tài chính chuyên nghiệp với amateur. Một network timeout = customer bị charge 2 lần. Bài này đi qua đầy đủ: vấn đề, giải pháp, code Node.js + PostgreSQL chạy production-grade.

Vấn đề: timeout = double charge

Khách hàng click "Pay" → app gửi POST /charge. Network timeout sau 30s. Client không biết server đã charge thành công hay chưa. Hai khả năng:

  1. Server chưa nhận → an toàn retry
  2. Server đã charge → retry = double charge

Không có cách phân biệt từ phía client. Nếu retry naive: 50% khả năng customer bị charge 2 lần. Đây là bài toán at-least-once vs exactly-once delivery — thuật toán phân tán cổ điển.

Giải pháp: client gán key cho mỗi intent

Client sinh UUID trước khi gọi. Khi retry, gửi LẠI cùng key. Server lưu key + result trong 24h:

Lần 1:
  Client → Server: POST /charge
                   Idempotency-Key: 8a3f...
                   { amount: 100, customer: 'cus_42' }
  Server: charge thành công, lưu (key, result)
          → response { id: 'ch_xyz', status: 'succeeded' }
  Network timeout — client không nhận được

Lần 2 (retry):
  Client → Server: POST /charge
                   Idempotency-Key: 8a3f...   (SAME key)
                   { amount: 100, customer: 'cus_42' }
  Server: thấy key 8a3f... đã xử lý
          → trả response cũ { id: 'ch_xyz', status: 'succeeded' }
  Customer chỉ bị charge 1 lần

Schema PostgreSQL

CREATE TABLE idempotency_keys (
  key            TEXT PRIMARY KEY,
  user_id        BIGINT NOT NULL,
  endpoint       TEXT NOT NULL,
  request_hash   TEXT NOT NULL,
  response_status INT,
  response_body  JSONB,
  state          TEXT NOT NULL DEFAULT 'pending', -- pending | done | failed
  created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at     TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours')
);

CREATE INDEX idx_ik_expires ON idempotency_keys(expires_at);

-- Cleanup cron: DELETE FROM idempotency_keys WHERE expires_at < NOW();

Implement đầy đủ Express + PostgreSQL

import crypto from 'node:crypto'

function hashPayload(body) {
  return crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex')
}

async function withIdempotency(req, res, handler) {
  const key = req.headers['idempotency-key']
  if (!key) return res.status(400).json({
    error: { code: 'MISSING_IDEMPOTENCY_KEY' }
  })

  const requestHash = hashPayload(req.body)
  const userId = req.user.id
  const endpoint = req.path

  // Try to insert key — race-safe
  const insertResult = await pool.query(`
    INSERT INTO idempotency_keys (key, user_id, endpoint, request_hash, state)
    VALUES ($1, $2, $3, $4, 'pending')
    ON CONFLICT (key) DO NOTHING
    RETURNING key
  `, [key, userId, endpoint, requestHash])

  if (insertResult.rowCount === 0) {
    // Key đã tồn tại — check state
    const existing = await pool.query(
      'SELECT * FROM idempotency_keys WHERE key = $1', [key]
    )
    const row = existing.rows[0]

    // Verify same request: ngăn lạm dụng key
    if (row.user_id !== userId || row.endpoint !== endpoint) {
      return res.status(409).json({
        error: { code: 'IDEMPOTENCY_KEY_CONFLICT',
                 message: 'Key dùng cho request khác' }
      })
    }
    if (row.request_hash !== requestHash) {
      return res.status(409).json({
        error: { code: 'IDEMPOTENCY_PAYLOAD_MISMATCH',
                 message: 'Payload khác request gốc' }
      })
    }

    if (row.state === 'done') {
      // Trả response cũ
      return res.status(row.response_status).json(row.response_body)
    }
    if (row.state === 'pending') {
      // Request gốc đang chạy. Client retry sớm — bảo họ chờ.
      return res.status(409).json({
        error: { code: 'REQUEST_IN_FLIGHT',
                 message: 'Request đang xử lý, retry sau' }
      })
    }
    // state === 'failed' — cho phép retry, update lại
  }

  // Xử lý handler
  try {
    const result = await handler(req)

    await pool.query(`
      UPDATE idempotency_keys
      SET response_status = $1, response_body = $2, state = 'done'
      WHERE key = $3
    `, [200, result, key])

    return res.status(200).json(result)
  } catch (err) {
    await pool.query(
      'UPDATE idempotency_keys SET state = $1 WHERE key = $2',
      ['failed', key]
    )
    throw err
  }
}

// Sử dụng:
app.post('/charge', requireAuth, async (req, res) => {
  await withIdempotency(req, res, async () => {
    const charge = await stripe.charges.create({
      amount: req.body.amount,
      customer: req.body.customer_id,
    })
    await db.charges.insert({ id: charge.id, user_id: req.user.id })
    return { charge_id: charge.id, status: charge.status }
  })
})

Race condition: 2 request cùng key đồng thời

Hai request cùng key đến cùng lúc. INSERT ... ON CONFLICT DO NOTHING đảm bảo chỉ 1 request giành được key — request kia rơi vào "key đã tồn tại" branch. Nếu state vẫn pending (request 1 chưa xong), trả 409 yêu cầu retry sau.

Một số design dùng SELECT ... FOR UPDATE để request 2 chờ. Trade-off: connection bị giữ lâu, có thể gây pool exhaustion. Trả 409 ngay an toàn hơn.

Payload mismatch detection

Stripe trả 422 nếu cùng key + payload khác. Lý do: client có bug — gán cùng key cho 2 intent khác nhau. Báo lỗi giúp dev fix sớm.

Nhưng cẩn thận: hash phải ổn định — order field trong JSON, whitespace, encoding. Cách an toàn: client gửi payload đã canonicalize (sort key) và server hash cùng cách.

TTL: 24 giờ là chuẩn

Stripe, Square, PayPal đều TTL 24h. Đủ cho mọi network/timeout retry hợp lý. Sau đó key reuse được không nhầm.

Cleanup cron đơn giản:

-- Chạy mỗi giờ
DELETE FROM idempotency_keys WHERE expires_at < NOW();

Client-side: sinh key đúng

// ❌ Sai — sinh key mới mỗi attempt
async function chargeWithRetry(amount) {
  for (let i = 0; i < 3; i++) {
    try {
      const key = uuid()  // ← sinh mới mỗi lần — phá idempotency
      return await fetch('/charge', { headers: { 'Idempotency-Key': key }, ... })
    } catch (e) { await sleep(1000 * (i+1)) }
  }
}

// ✓ Đúng — gán key 1 lần, reuse khi retry
async function chargeWithRetry(amount) {
  const key = uuid()  // ← cố định cho cả 3 attempt
  for (let i = 0; i < 3; i++) {
    try {
      return await fetch('/charge', { headers: { 'Idempotency-Key': key }, ... })
    } catch (e) { await sleep(1000 * (i+1)) }
  }
}

Kết luận

Idempotency keys không khó implement nhưng dễ làm sai chi tiết. UNIQUE constraint chống race, payload hash chống lạm dụng, TTL 24h cleanup tự động. Sau khi triển khai, anh có thể retry thoải mái mà customer không bao giờ bị charge 2 lần. Tham khảo thêm Webhook design vì cùng nguyên tắc — at-least-once delivery cần idempotent receiver.

Zalo