Idempotency — API

Idempotency: memanggil operasi yang sama berkali-kali hasilnya sama seperti memanggil sekali. Konsep kritis untuk API yang reliable — tanpa ini, retry dari clie

Idempotency: memanggil operasi yang sama berkali-kali hasilnya sama seperti memanggil sekali. Konsep kritis untuk API yang reliable — tanpa ini, retry dari client bisa charge user 10 kali untuk 1 order.

Kenapa Penting?

Skenario real: user bayar e-wallet, koneksi putus setelah server terima request tapi sebelum response kembali. Client tidak tahu status — success atau fail? Client safe-default: retry.

Tanpa idempotency:

POST /payments { amount: 100000 }
[koneksi putus]
POST /payments { amount: 100000 }  ← retry
  → Hasil: user dicharge 2x = 200000

Dengan idempotency:

POST /payments { amount: 100000 }
  Headers: Idempotency-Key: uuid-abc-123
[koneksi putus]
POST /payments { amount: 100000 }  ← retry dengan SAME key
  Headers: Idempotency-Key: uuid-abc-123
  → Server detect duplicate → return response pertama (atau status "processing")
  → User dicharge 1x saja ✓

HTTP Methods: Mana yang Natively Idempotent?

Method Idempotent? Kenapa
GET Read-only, tidak mengubah state
HEAD Read-only
PUT Full replace — hasil akhir sama
DELETE "Hapus object X" — hasil akhir sama (object sudah tidak ada)
PATCH ❌ Tergantung Partial update bisa non-idempotent (misal {counter: +1})
POST Default create baru — tiap call = row baru

POST dan PATCH butuh effort tambahan untuk jadi idempotent.

Pola 1: Idempotency-Key Header (Stripe-style)

Gold standard. Client generate unique key per "logical request", server dedupe.

Client:

const idempotencyKey = crypto.randomUUID();  // generate sekali saat user klik "Bayar"

async function pay(amount) {
  return fetch("/api/payments", {
    method: "POST",
    headers: {
      "Idempotency-Key": idempotencyKey,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ amount }),
  });
}

// Retry pakai key yang SAMA supaya server dedupe

Server:

app.post("/api/payments", async (req, res) => {
  const key = req.headers["idempotency-key"];

  if (!key) return res.status(400).json({ error: "Idempotency-Key required" });

  // Cek apakah sudah pernah diproses
  const existing = await redis.get(`idempotency:${key}`);
  if (existing) {
    return res.json(JSON.parse(existing));  // return response asli
  }

  // Mark sebagai "processing" untuk prevent race
  await redis.set(`idempotency:${key}:lock`, "1", "EX", 30, "NX");

  try {
    const payment = await processPayment(req.body);

    // Cache response untuk 24 jam
    await redis.set(`idempotency:${key}`, JSON.stringify(payment), "EX", 86400);
    res.json(payment);
  } catch (err) {
    await redis.del(`idempotency:${key}:lock`);
    throw err;
  }
});

Kenapa 24 jam? Window yang cukup untuk retry dari mobile yang offline lalu online, tapi tidak terlalu panjang supaya storage tidak meledak. Stripe pakai 24 jam.

Pola 2: Natural Idempotency via Unique Constraint

Kalau kamu bisa identify "logical request" dari data sendiri (bukan key terpisah), pakai unique constraint di DB:

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INT,
  product_id INT,
  client_order_id VARCHAR(100),
  -- Unique per user
  UNIQUE(user_id, client_order_id)
);

Kalau client retry dengan client_order_id yang sama, INSERT gagal → catch error → return order existing.

try {
  const order = await db.query(
    "INSERT INTO orders (user_id, product_id, client_order_id) VALUES ($1, $2, $3) RETURNING *",
    [userId, productId, clientOrderId]
  );
  res.status(201).json(order);
} catch (err) {
  if (err.code === "23505") {  // PostgreSQL unique violation
    const existing = await db.query(
      "SELECT * FROM orders WHERE user_id = $1 AND client_order_id = $2",
      [userId, clientOrderId]
    );
    res.status(200).json(existing);  // not 201, karena tidak baru
  } else {
    throw err;
  }
}

Retry Safety — Client-Side

Kalau API kamu idempotent, client bisa retry dengan aman. Pola exponential backoff:

async function callWithRetry(url, options, maxRetries = 3) {
  for (let i = 0; i <= maxRetries; i++) {
    try {
      const res = await fetch(url, options);
      if (res.status < 500) return res;  // 4xx jangan retry
      if (i === maxRetries) return res;
    } catch (err) {
      if (i === maxRetries) throw err;
    }
    // Exponential backoff: 1s, 2s, 4s
    await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
  }
}

Pitfalls

Simpan idempotency key selamanya — storage bengkak. Set TTL 24 jam biasanya cukup.

Tidak lock saat processing — concurrent retry bisa eksekusi 2x sebelum cache terisi. Pakai distributed lock (Redis SETNX + expiry).

Key terlalu pendek / predictable — pakai UUID v4 random, minimal 64-bit entropy.

Response cached mengandung timestamp dinamis — "created_at: now()" berubah tiap call. Cache response lengkap apa adanya dari eksekusi pertama.

Use Cases Populer

Checklist

Yang akan kamu pelajari