Operasi yang idempotent adalah operasi yang dijalankan berkali-kali efeknya sama dengan dijalankan sekali. Bayangin tombol lift. Kamu pencet "5" sekali, lift ke lantai 5. Kamu pencet lima kali karena gak sabar, lift tetap ke lantai 5, bukan ke lantai 25. Bandingkan dengan tombol "Pesan" di aplikasi makanan yang lagi lemot: dipencet lima kali, bisa datang lima piring. Di distributed system, retry pasti terjadi, jadi tiap aksi penting harus dirancang seperti tombol lift.
🧪 Tebakan cepat: service
bayarmengingat tiap Idempotency-Key selama 24 jam. HP Budi offline semalaman, lalu mengirim ulang pembayaran pesanan #1001 dengan key yang sama 26 jam kemudian. Berapa kali kartu Budi terpotong? Tebak dulu, jawabannya ada di bagian "TTL: berapa lama key diingat".
Kenapa retry gak bisa dihindari
Di lesson Kenapa Distributed System Sulit, order meminta bayar memotong kartu Budi Rp1.500.000 untuk pesanan #1001, lalu timeout setelah 2 detik. Timeout artinya "gak tahu": request-nya bisa hilang, response-nya bisa hilang, atau bayar cuma lambat. Pemanggil yang ingin pembayarannya sampai harus retry, dan begitu ada retry, server bisa menerima aksi yang sama dua kali. Tugas desainnya: pastikan aksi kedua gak menambah efek.
Alami idempotent atau gak
Sebagian operasi sudah idempotent dari sananya:
| Operasi | Idempotent? | Kenapa |
|---|---|---|
GET /api/events/42 |
ya | cuma membaca |
PUT /api/users/7 dengan seluruh isi profil |
ya | mengganti state dengan nilai yang sama |
DELETE /api/orders/1001 |
ya | dihapus dua kali, tetap terhapus |
UPDATE saldo SET jumlah = 500000 |
ya | menyetel nilai tetap |
POST /api/orders |
gak | tiap panggilan membuat pesanan baru |
UPDATE saldo SET jumlah = jumlah + 100000 |
gak | dua kali jalan, bertambah 200.000 |
Aturan HTTP-nya: GET, PUT, DELETE idempotent menurut spesifikasi; POST dan PATCH gak. Jadi retry PUT dengan body yang sama aman tanpa tambahan apa-apa. Yang butuh perlindungan adalah aksi seperti "potong kartu", "buat pesanan", "kirim e-tiket".
Idempotency key: satu aksi, satu key
Caranya: client membuat Idempotency-Key unik (biasanya UUID) sekali per aksi, lalu mengirim key yang sama di tiap percobaan. Server menyimpan key itu bersama hasilnya di dedupe store (Redis atau tabel database). Saat key yang sama datang lagi, server melihat catatannya:
| Keadaan key di store | Jawaban bayar |
Kartu dipotong? |
|---|---|---|
| belum ada | potong kartu, simpan hasil, 201 Created |
ya, sekali |
| ada, sudah selesai | kirim ulang hasil yang tersimpan (replay) | gak |
| ada, masih diproses | 409 Conflict: coba lagi nanti |
gak |
| ada, tapi body-nya beda | 422: key dipakai untuk aksi lain |
gak |
// bayar: dedupe store per Idempotency-Key (model contoh)
const TTL_JAM = 24; // key diingat 24 jam
const store = new Map(); // key -> { status, body, response, sampaiJam }
let terpotong = 0;
const potongKartu = () => new Promise((ok) => setTimeout(() => ok("ch_" + ++terpotong), 120));
async function charge(req, jam) {
const body = JSON.stringify(req.body);
const lama = store.get(req.key);
if (lama && jam < lama.sampaiJam) {
if (lama.body !== body) return { status: 422 }; // key yang sama untuk aksi lain
if (lama.status === "processing") return { status: 409 }; // percobaan pertama masih jalan
return lama.response; // replay: kartu gak dipotong lagi
}
const entry = { status: "processing", body, sampaiJam: jam + TTL_JAM };
store.set(req.key, entry); // klaim key SEBELUM memotong kartu
entry.response = { status: 201, charge: await potongKartu() };
entry.status = "done";
return entry.response;
}
async function main() {
const req = { key: "ord-1001", body: { pesanan: 1001, jumlah: 1500000 } };
const [a, b] = await Promise.all([charge(req, 0), charge(req, 0)]); // klik dua kali
console.log(a.status, a.charge, "|", b.status); // 201 ch_1 | 409
const c = await charge(req, 1); // retry satu jam kemudian
console.log(c.status, c.charge); // 201 ch_1 (replay)
const d = await charge(req, 26); // retry 26 jam kemudian
console.log(d.status, d.charge, "| terpotong:", terpotong); // 201 ch_2 | terpotong: 2
}
main();
store.get(req.key)dicek sebelum memotong kartu. Kalau urutannya terbalik, kartu sudah terpotong saat kamu baru tahu itu duplikat.store.set(req.key, entry)dengan status"processing"mengklaim key sebelum kerja dimulai. Karena itu klik kedua yang datang selagi kartu dipotong dapat409, bukan potongan kedua.return lama.responsemengembalikan objek hasil yang sama persis (ch_1). Client gak bisa membedakan replay dari jawaban asli, dan memang gak perlu.sampaiJamadalah batas umur key. Lewat dari itu, key dianggap baru.
Di Redis sungguhan, klaim key harus atomic (satu perintah yang mengecek dan menyimpan sekaligus), misalnya SET idem:ord-1001 processing NX EX 86400. NX artinya "simpan hanya kalau belum ada", jadi dua server yang mengklaim bersamaan gak bisa sama-sama menang. Detail header-nya di HTTP ada di lesson Idempotency (track API).
At-least-once: pesan yang datang dua kali
Message queue di lesson Message Queue menjamin at-least-once delivery: tiap pesan pasti sampai, tapi bisa lebih dari sekali. Worker e-tiket mengirim e-tiket pesanan #1001, lalu mati sebelum sempat ack (memberi tahu queue "sudah beres"). Setelah visibility timeout 10 detik, queue mengirim pesan itu lagi ke worker lain, dan Budi menerima dua e-tiket.
Tiga jaminan pengiriman yang perlu kamu kenal:
- At-most-once: dikirim paling banyak sekali. Gak pernah dobel, tapi bisa hilang.
- At-least-once: gak pernah hilang, tapi bisa dobel. Ini yang dipakai kebanyakan queue (SQS, RabbitMQ).
- Exactly-once: dalam praktik dicapai dengan at-least-once ditambah consumer yang idempotent.
Consumer yang idempotent menyimpan ID pesan yang sudah diproses: SET diproses:msg-1001 1 NX EX 86400 di Redis (versi lamanya perintah SETNX plus TTL). Balasan OK artinya pesan baru, kerjakan; balasan kosong artinya sudah pernah, lewati dan ack saja. Ini lebih aman daripada "cek database dulu, baru insert": dua worker bisa sama-sama melihat "belum ada" di antara cek dan insert. Satu hal: kalau worker mati setelah SET tapi sebelum kerjanya selesai, pesan berikutnya ikut dilewati, jadi untuk kerja yang mahal simpan status processing dan done seperti dedupe store bayar.
TTL: berapa lama key diingat
Key gak disimpan selamanya. TTL (time to live) adalah umur key di store. Stripe, misalnya, menyimpan key minimal 24 jam. Di TiketNada: 50.000 pesanan per hari × sekitar 1 KB per catatan = 50 MB untuk 24 jam, murah.
Kembali ke tebakan: key ord-1001 disimpan di jam 0 dan dilupakan di jam 24. Retry datang di jam 26, saat key sudah gak ada, jadi bayar menganggapnya pembayaran baru. Jawabannya 2 kali. Itu baris terakhir output kode tadi.
- TTL terlalu pendek: retry yang sah datang setelah key dilupakan, dan aksinya terjadi dua kali.
- TTL terlalu panjang: store penuh key lama yang gak akan pernah di-retry.
Pilih TTL lebih panjang dari jeda retry terlama client-mu.
Retry yang sopan: backoff dan jitter
Retry hanya untuk error yang mungkin sembuh sendiri: error jaringan, 408, 429, 500, 502, 503, 504. Jangan retry 400, 401, 403, 404, atau 422: dikirim berapa kali pun jawabannya sama. Beri jeda yang membesar (exponential backoff: 1, 2, 4 detik) dan acak di dalamnya (jitter: jeda acak antara 0 dan batasnya), supaya ribuan client yang gagal bersamaan gak menyerbu server tepat di detik yang sama saat dia baru pulih.
Desain ulang supaya alami idempotent
Kadang lebih bersih mengubah bentuk aksinya daripada menempel key. Tombol "suka" di halaman konser: POST /api/events/42/likes/increment gak idempotent, dua klik = dua suka. Ubah jadi mencatat siapa yang suka: POST /api/events/42/likes dengan { userId: 7 }, disimpan sebagai pasangan unik (konser, user), dan server yang menghitung totalnya. Diklik berapa kali pun, Budi tetap tercatat sekali. Sama juga dengan keranjang: PUT /cart/items/7 { jumlah: 3 } lebih aman daripada "tambah 1".
⚠️ Jebakan yang sering ketemu
- Membuat key baru di tiap retry. Key harus dibuat sekali per aksi dan dipakai ulang. UUID baru per percobaan sama saja dengan tanpa key.
- Key dibuat di server. Client gak tahu key-nya, jadi gak bisa mengirim key yang sama saat retry.
- Cek lalu kerja tanpa klaim atomic. Dua request yang datang bersamaan sama-sama melihat "belum ada", lalu sama-sama memotong kartu.
- Cuma men-disable tombol di frontend. Membantu, tapi retry otomatis, tab kedua, dan request manual tetap lewat. Perlindungannya harus di server.
- Lupa layanan eksternal. Kalau
bayarmemanggil payment gateway, teruskan key-nya ke sana juga, atau satu retry di dalambayarbisa memotong kartu dua kali.
TL;DR: Idempotent artinya dijalankan berkali-kali efeknya sama dengan sekali. GET, PUT, DELETE idempotent menurut spesifikasi; POST dan PATCH gak, jadi aksi seperti bayar dan pesan butuh Idempotency-Key: dibuat client sekali per aksi (UUID), dikirim sama di tiap retry. Server menyimpan key dan hasilnya di dedupe store: key baru diproses, key yang sudah selesai dijawab dengan hasil tersimpan (replay), key yang masih diproses dijawab 409. Klaim key harus atomic (SET ... NX). Queue memberi at-least-once delivery, jadi consumer juga harus idempotent: simpan ID pesan dengan SET NX plus TTL, dan lewati yang sudah pernah. TTL adalah trade-off: terlalu pendek membuat retry yang sah diproses dua kali (key 24 jam, retry di jam 26, kartu terpotong dua kali), terlalu panjang memenuhi store. Retry hanya error yang bisa sembuh, dengan backoff dan jitter. Kalau bisa, desain ulang aksinya supaya alami idempotent.