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 jitterdelay = 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