Saldo Naik Empat Kali untuk Satu Pembayaran

Kodenya nggak salah, dan gateway-nya juga nggak salah. Tapi satu pembayaran muncul empat kali di ledger. Coba tebak kenapa — lalu bikin make check hijau di lab-nya.

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:

  1. Datang lebih dari sekali. Gateway mengirim ulang karena jawabannya nggak sampai. Lab ini soal yang ini.
  2. Datang dari dua jalur. Webhook yang telat, plus job rekonsiliasi yang keburu menutup hari itu. Sudutnya ada di lab satunya.
  3. 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:

  1. 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.
  2. Pengiriman ulang dijawab seperti jawaban pertama. Bukan error, bukan pesan "sudah diproses" — body yang sama, dengan entry_id yang sama. Alasannya bukan sekadar sopan: jawaban yang dihitung ulang dari keadaan database saat itu bisa salah, karena saldonya sudah berubah oleh event lain.
  3. event_id yang dipakai ulang dengan isi berbeda tidak ikut diproses. Nominal yang berbeda untuk event yang sama tidak boleh masuk ke ledger.
  4. 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:

BASH
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.

Biar makin jago ngoding.

Yuk gabung bareng temen-temen developer lainnya buat dapet update teknologi, tips, dan tutorial santai tiap minggu.

Biar Nggak Kudet

Update teknologi, tips ngoding, dan insight santai langsung ke email kamu tiap minggu.

Santai, anti spam. Bisa berhenti langganan kapan aja.

Baca Selanjutnya

Lihat semua

© 2026 Inva.dev.