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:
- Apakah request sampai ke server? (Mungkin sampai, ack yang hilang.)
- Apakah server sudah eksekusi? (Mungkin sudah, response hilang.)
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
- Storage cost: idempotency key perlu disimpan untuk window waktu (Stripe: 24 jam). Untuk 1 juta request/hari = 1 juta key di Redis. Ukuran manageable, tapi harus di-monitor.
- TTL key: terlalu pendek: retry legitimate bisa miss, dobel-process. Terlalu panjang: storage meledak. Default 24 jam biasanya aman.
- Key scoping: apakah key global atau per-user? Per-user lebih aman (user A dan B bisa punya key sama tanpa conflict).
- Idempotent dengan side-effect eksternal: kalau endpoint-mu panggil external API (Stripe), pastikan external itu juga punya idempotency. Kalau tidak, kamu harus track status lokal.
Kapan Idempotency Wajib
- Payment, money transfer, bill pay
- Create order, invoice, booking
- Send email, SMS, push notification
- Provisioning resource (cloud VM, storage)
- Apapun dengan side effect eksternal yang tidak bisa di-undo
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:
- GET, HEAD, OPTIONS = idempotent by spec
- PUT, DELETE = idempotent by spec
- POST, PATCH = NOT idempotent by default: wajib design idempotency key
- Custom verb (charge, transfer) = ALWAYS idempotency key
⚠️ Jebakan klasik:
- Generate idempotency key di server = useless (client gak bisa retry dengan key sama)
- Client UUID setiap request = setiap retry generate baru = duplicate. Generate sekali per logical operation
- Lupa cek key sebelum side effect = race condition, dobel-process
- TTL terlalu pendek = retry legitimate miss → dobel charge
- Idempotency hanya di endpoint utama, lupa external API = Stripe charge dobel
🎯 Pattern idempotency:
- Client generate UUID unik per logical operation
- Server check Redis
idempotency:{key}, exists? Return cached response - Lock key sambil proses (cegah race)
- Process + simpan result + TTL 24h
- 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.