Sebuah merchant komplain: saldonya bertambah, tapi bukan dengan jumlah yang benar. Di laporan mutasi, satu pembayaran senilai dua puluh lima ribu muncul empat kali. Nilai saldonya naik empat kali lipat dari yang seharusnya.
Kamu buka kodenya. Endpoint POST /webhooks/payments — satu request masuk, satu baris ledger
keluar, saldo ditambah. Nggak ada try/except yang mencurigakan, nggak ada perulangan,
nggak ada bug. Kalau kamu tes dengan mengirim request yang sama dua kali, hasilnya ya dua baris —
tapi kamu juga yang mengirimnya dua kali, jadi itu wajar.
Saya pernah ketemu sepupunya masalah ini di kerjaan pertama saya: bukan saldo yang dobel, tapi stok gudang yang angkanya ketinggalan beberapa langkah, dan barangnya keburu kebeli orang sebelum angkanya membaik. Bentuknya beda, akarnya sama — dan itu sebabnya lab ini ada. Ceritanya di sini.
Yang nggak kelihatan: HTTP nggak punya cara memberi tahu pengirim bahwa jawaban kita sampai. Gateway mengirim eventnya, kita commit transaksinya, lalu jawabannya hilang di jalan — koneksi putus, load balancer timeout, container kita di-restart sedetik terlalu cepat. Dari sisi kita semuanya beres. Dari sisi gateway, eventnya belum pernah diterima, jadi dia kirim lagi.
Itu bukan gateway yang rusak. Itu satu-satunya pilihan yang dia punya, dan dia memilih dobel daripada hilang — karena kehilangan pembayaran lebih mahal daripada mengulangnya. Dobelnya jadi pekerjaanmu.
Ini yang terjadi kalau kamu jalankan lab-nya:
| kondisi awal | |
|---|---|
| satu event dikirim ulang 4× | 4 baris ledger untuk 1 event, saldo naik 4× nominal |
| satu event dikirim 30× sekaligus | 30 baris, dan saldo cuma naik sebagian (40.000–60.000 dari 300.000 yang seharusnya) |
| 3 event berbeda, masing-masing dikirim 2× | 6 baris, saldo naik 6× nominal |
| request yang persis sama dikirim 2× | 2 baris — nggak ada apa pun di database yang bisa membedakan mana yang sudah pernah diproses |
Baris kedua itu layak dibaca ulang. Dua puluh baris masuk ke ledger, tapi saldonya cuma naik dua kali — artinya ada dua masalah berbeda di satu endpoint, dan yang kedua bukan soal idempotensi sama sekali.
Kejadian yang sama bisa sampai ke sistemmu lewat tiga cara, dan ketiganya pernah saya alami:
- Datang lebih dari sekali. Gateway mengirim ulang karena jawabannya nggak sampai. Lab ini soal yang ini.
- Datang dari dua jalur. Webhook yang telat, plus job rekonsiliasi yang keburu menutup hari itu. Sudutnya ada di lab satunya.
- Datang tidak berurutan. Update yang datang belakangan membawa data yang lebih tua, dan yang lebih tua menang. Ini yang saya alami di stok gudang, dan bentuknya beda dari dua yang di atas.
Akarnya satu: keputusan "kejadian ini sudah pernah diterapkan atau belum" diambil berdasarkan kedatangan pesannya, di aplikasi. Yang berhak memutuskan bukan aplikasimu, dan yang jadi patokan bukan urutan kedatangan.
Poin Penting
Empat baris ledger untuk satu pembayaran, dan nggak ada bug di kodenya.
- HTTP nggak bisa memberi tahu pengirim bahwa jawabannya sampai, jadi pengiriman ulang itu normal, bukan kesalahan siapa pun.
- Gateway memilih dobel daripada hilang; dobelnya yang jadi pekerjaanmu.
- Dua puluh baris masuk ke ledger sementara saldo cuma naik dua kali: ada dua masalah berbeda di satu endpoint.
- Pertanyaannya bukan "di mana bugnya", tapi siapa yang berhak memutuskan bahwa sebuah pembayaran sudah pernah diterima.
Misi lab-nya
Empat syarat, dan make check memeriksa keempatnya:
- Tepat sekali. Satu event diterapkan tepat satu kali. Tidak dobel, dan tidak ada yang hilang: tiga event berbeda tetap jadi tiga baris ledger, bukan enam, bukan dua.
- Pengiriman ulang dijawab seperti jawaban pertama. Bukan error, bukan pesan "sudah
diproses" — body yang sama, dengan
entry_idyang sama. Alasannya bukan sekadar sopan: jawaban yang dihitung ulang dari keadaan database saat itu bisa salah, karena saldonya sudah berubah oleh event lain. event_idyang dipakai ulang dengan isi berbeda tidak ikut diproses. Nominal yang berbeda untuk event yang sama tidak boleh masuk ke ledger.- Saldo tetap benar saat banyak event datang bersamaan. Tiga puluh event berbeda yang datang sekaligus harus menaikkan saldo tiga puluh kali nominal, bukan dua kali.
Kalau kamu mau mencobanya
Lab-nya repo publik, semuanya jalan di dalam Docker — nggak perlu install Postgres, Python, atau apa pun:
git clone https://github.com/izzudd/inva-lab.git
cd inva-lab/idempotency
make up # database + API
make replay # putar ulang satu event, seperti yang dilakukan gateway
make check # target: 21 pemeriksaan, semua hijau
Ada HINTS.md dengan tiga tingkat petunjuk, dan aturan mainnya satu: jangan buka branch solusi
sebelum kamu benar-benar mentok.
Pertanyaannya
Sebelum kamu menambahkan pengecekan apa pun, jawab ini dulu: kenapa gateway mengirim ulang, padahal permintaan pertamanya dibalas 200?
Kalau jawabannya belum jelas, semua tambalan yang kamu tulis cuma menutupi gejala. Dan begitu jawabannya jelas, muncul pertanyaan kedua yang lebih menarik: kalau pengiriman ulang itu normal dan bukan kesalahan siapa pun, siapa yang seharusnya memutuskan bahwa pembayaran ini sudah pernah diterima — aplikasimu, atau database-nya?
Versi panjangnya, lengkap dengan argumen kenapa menambah UNIQUE saja justru bikin masalah baru,
ada di artikel jawabannya.