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.