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
- Payment: wajib! Stripe, Midtrans, Xendit semua support Idempotency-Key
- Send email/SMS: idempotency key = notification_id, supaya tidak spam user
- Create resource dengan external side-effect: upload file, kirim ke queue
- Webhook delivery: receiver pakai event_id untuk dedupe
Checklist
- Semua POST yang ada side-effect (payment, email, SMS) support Idempotency-Key
- Lock mekanisme saat processing (Redis SETNX + TTL)
- Cache response 24 jam (TTL)
- Document di API spec: endpoint mana yang butuh key, kapan safe retry
- Client generate UUID sekali per "logical action"
- 500 error tidak cache response (biar retry bisa re-execute)