Idempotency & Retry Safety — System Design

Idempotensi berarti: operasi yang dijalankan berkali-kali menghasilkan efek yang sama seperti dijalankan sekali. Sekilas abstrak — tapi di sistem…

Idempotensi berarti: operasi yang dijalankan berkali-kali menghasilkan efek yang sama seperti dijalankan sekali. Sekilas abstrak, tapi di sistem terdistribusi, ini salah satu property terpenting yang bisa kamu desain.

Kenapa Ini Masalah Besar

Di sistem terdistribusi, retry itu tak terhindarkan. Network putus, timeout terjadi, client me-retry. Kamu tidak bisa tahu:

Kalau operasi tidak idempotent, retry bisa mendobel efek: uang dikirim dua kali, order dibuat dua kali, email terkirim dua kali.

Client → POST /charge (Rp 500.000)
Server memproses, charge kartu kredit berhasil.
Response 200 hilang di jalan pulang.
Client timeout, auto-retry POST /charge (Rp 500.000).
Server charge lagi. User dikurangi Rp 1.000.000 untuk order Rp 500.000.

Ini bukan skenario hipotetis, ini sebabnya Stripe, PayPal, AWS semua memaksa pattern idempotency.

Operasi yang Alami Idempotent vs Tidak

Operasi Idempotent? Kenapa
GET /users/42 Ya Baca tidak mengubah state
PUT /users/42 { name: "Ali" } Ya Full state replacement, idempotent
DELETE /orders/7 Ya Delete dua kali = tetap terhapus
POST /orders { items: [...] } Tidak Setiap POST bikin order baru
UPDATE balance SET amount = amount + 100 Tidak Jalankan 2x = +200
UPDATE balance SET amount = 500 Ya Set ke nilai tetap

Aturan: HTTP GET, PUT, DELETE idempotent by spec. POST dan PATCH tidak.

Pola 1: Idempotency Key + Dedup Store

Client generate UUID unik per operasi. Server simpan key + hasil di store (Redis/DB).

// Client
const idempotencyKey = crypto.randomUUID();

await fetch("/api/payments", {
  method: "POST",
  headers: {
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ amount: 500000, cardToken: "tok_abc" })
});

// Kalau network gagal → retry dengan idempotencyKey SAMA

Server-side:

async function handlePayment(req, res) {
  const key = req.headers["idempotency-key"];
  if (!key) return res.status(400).send("Idempotency-Key required");

  // Cek apakah key pernah diproses (dalam 24 jam terakhir)
  const existing = await redis.get("idem:" + key);
  if (existing) return res.json(JSON.parse(existing));

  // Reserve key supaya retry paralel tidak double-process
  const locked = await redis.set("idem:" + key + ":lock", "1", "NX", "EX", 30);
  if (!locked) return res.status(409).send("Request in progress");

  try {
    const result = await chargeStripe(req.body);
    await redis.set("idem:" + key, JSON.stringify(result), "EX", 86400); // 24h
    return res.json(result);
  } finally {
    await redis.del("idem:" + key + ":lock");
  }
}

Ini pola yang Stripe pakai di Idempotency-Key header mereka: menjadi de-facto standard di industry.

Pola 2: Natural Idempotency via PUT

Desain operasi sehingga secara alami idempotent. Gunakan PUT dengan full state, bukan POST + delta.

// Anti-pattern (tidak idempotent):
POST /cart/add { itemId: 7, quantity: 1 }   // retry = +1 lagi

// Idempotent:
PUT /cart/items/7 { quantity: 3 }   // retry = tetap 3

Pola 3: Conditional Updates dengan Version Number

Optimistic concurrency: client kirim versi yang ia tahu, server reject kalau sudah berubah.

UPDATE accounts
SET balance = 900, version = version + 1
WHERE id = 42 AND version = 5;

Retry aman: query kedua dengan version = 5 tidak akan match (sudah jadi 6), jadi skip tanpa error.

Retry Strategy: Exponential Backoff + Jitter

Retry bodoh (loop langsung) menyebabkan thundering herd, ribuan client me-retry bersamaan, menghantam server yang baru pulih.

async function retryWithBackoff(fn, maxRetries = 5) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxRetries - 1) throw err;
      if (!isRetryable(err)) throw err;

      // Exponential backoff: 1s, 2s, 4s, 8s, 16s
      const base = Math.min(1000 * Math.pow(2, attempt), 30000);

      // Full jitter: random antara 0 sampai base
      const delay = Math.random() * base;

      await new Promise(r => setTimeout(r, delay));
    }
  }
}

Jitter (randomisasi) mencegah semua client retry di detik yang sama. AWS paper merekomendasikan full jitter, delay = random(0, base), bukan versi "equal jitter" yang lebih lemah.

Hanya retry untuk error yang retryable: 408, 429, 500, 502, 503, 504, network error. Jangan retry 400/401/403/404, itu bukan transient.

Trade-off

Kapan Idempotency Wajib

Aturan pragmatis: semua POST dan PATCH endpoint yang punya side effect di external system harus menerima idempotency key.

Idempotency bukan tambahan mewah, ia adalah fondasi untuk retry yang aman di sistem terdistribusi. Tanpa ia, kamu tidak bisa pernah yakin operasi tidak terjadi dua kali.

🎭 Analogi sehari-hari: Lift apartemen. Pencet tombol lantai 5 sekali, lift ke lantai 5. Pencet 5x bertubi-tubi karena lift gak responsif? Tetap ke lantai 5 saja, gak ke lantai 25. Itulah idempotent. Beda dengan order makanan, pencet "order" 5x = 5 piring datang. Itu NOT idempotent.

💡 Aturan idempotensi by HTTP method:

⚠️ Jebakan klasik:

🎯 Pattern idempotency:

  1. Client generate UUID unik per logical operation
  2. Server check Redis idempotency:{key}, exists? Return cached response
  3. Lock key sambil proses (cegah race)
  4. Process + simpan result + TTL 24h
  5. Return dengan status code + cached response

TL;DR: Idempotensi = retry safety. Sama operation N kali = sama hasil. POST/PATCH wajib idempotency key. Critical untuk payment, order, provisioning. Pattern: client generate UUID + server cache result + TTL. Tanpa ini, distributed retry = bug nest.

Yang akan kamu pelajari