Idempotency & Retry Safety — System Design

Idempotensi berarti: operasi yang dijalankan berkali-kali menghasilkan efek yang sama seperti dijalankan sekali. Sekilas abstrak — tapi di sistem…

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 bayar mengingat 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();

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:

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.

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

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.

Yang akan kamu pelajari