Jumat malam, satu email support masuk ke inbox tracker langganan yang kubangun sendiri: "Kak, kartu saya kena tagih dua kali bulan ini, tolong dicek." Aku buka dashboard payment gateway, dan benar—dua transaksi sukses, jumlah sama persis, jaraknya cuma 40 detik. Bukan pelanggan yang klik dua kali. Sistemku sendiri yang mengeksekusi charge yang sama, dua kali, tanpa ada yang memintanya.
Aku cek log worker yang menangani webhook payment.succeeded. Ternyata payment gateway mengirim webhook itu dua kali untuk satu transaksi—bukan bug di sisi mereka, tapi perilaku yang memang didokumentasikan: kalau endpoint-ku tidak membalas 200 dalam beberapa detik, mereka retry. Endpoint-ku memang sempat lambat malam itu karena ada query berat lain yang jalan bersamaan, jadi request pertama belum selesai diproses saat webhook kedua datang dan mulai diproses juga—dua proses paralel, sama-sama membaca status "belum lunas", sama-sama memutuskan untuk menagih.
Asumsi yang kupegang tanpa pernah kuuji
Waktu menulis handler webhook itu beberapa bulan lalu, aku menganggap "satu event, satu request" sebagai kebenaran yang tidak perlu dipertanyakan. Dokumentasi payment gateway sebenarnya sudah menyebut soal retry dan menyarankan idempotency key di baris yang sama tempat aku membaca cara membaca payload—tapi aku skip bagian itu karena waktu itu terburu-buru mengejar fitur lain, dan toh di testing manual webhook memang selalu datang sekali. Yang tidak pernah kusimulasikan: endpoint yang lambat merespons, dan efek retry yang muncul persis di kondisi itu.
Yang bikin ini lolos bertahan lama: kondisinya jarang terjadi. Butuh dua hal bersamaan—payment gateway retry karena respons lambat, dan dua proses handler yang benar-benar tumpang tindih membaca-lalu-menulis status yang sama sebelum salah satu sempat menyelesaikan transaksinya. Di lalu lintas rendah, endpoint hampir selalu cepat, jadi retry nyaris tidak pernah kejadian. Begitu jumlah pelanggan naik dan server sesekali sibuk, jendela waktu untuk dua proses bertabrakan itu mulai terbuka, dan satu-dua kali sebulan, seseorang kena charge dobel.
Kenapa "cek dulu baru catat" tidak cukup
Kode lamaku melakukan hal yang terdengar masuk akal: cek status langganan, kalau belum ditagih bulan ini, jalankan charge, lalu tandai sudah ditagih. Masalahnya, "cek" dan "tandai" adalah dua langkah terpisah, dan di antara keduanya ada jeda—waktu tunggu jaringan ke payment gateway. Kalau dua proses sama-sama lewat langkah "cek" sebelum salah satu sempat sampai ke langkah "tandai", keduanya sama-sama melihat status yang sama dan sama-sama menyimpulkan "belum ditagih, aman untuk jalan". Ini persis pola yang biasa disebut check-then-act race condition, dan payment adalah salah satu tempat paling mahal untuk dia muncul, karena setiap kegagalan bukan cuma bug di layar—dia jadi uang nyata yang keluar dari rekening orang.
Perbaikan yang benar bukan "bikin endpoint lebih cepat supaya retry jarang terjadi"—itu cuma mengecilkan peluang, bukan menghilangkan kemungkinan. Yang kupasang sekarang: setiap event dari payment gateway punya event_id unik, dan sebelum memproses apa pun, worker mencoba menyimpan event_id itu ke tabel kecil dengan constraint unique di database. Kalau insert gagal karena event_id sudah ada, worker berhenti di situ juga—tidak ada charge kedua, tidak ada race, karena constraint unique di database dijamin atomik, tidak seperti cek-lalu-tulis di level aplikasi yang bisa diselang proses lain.
Idempotency bukan fitur ekstra, itu kontrak minimum
Yang paling mengganggu dari insiden ini bukan bug-nya sendiri—bug selalu ada kemungkinan muncul. Yang mengganggu adalah aku menulis kode yang menyentuh uang orang lain sambil menganggap "event ini pasti cuma datang sekali" sebagai default, padahal setiap sistem webhook yang serius justru mendesain sebaliknya: asumsikan event bisa datang berkali-kali, dan buat sisi penerima yang bertanggung jawab menjaga supaya efeknya cuma terjadi sekali. Aku membalik tanggung jawab itu ke arah yang salah selama berbulan-bulan, dan baru sadar setelah ada orang yang benar-benar dirugikan.
Sekarang, setiap kali aku mengintegrasikan webhook apa pun yang menyentuh efek yang tidak boleh dobel—charge, kirim email, kurangi stok—pertanyaan pertama yang kutanyakan bukan lagi "bagaimana cara membaca payload-nya", tapi "apa yang terjadi kalau event ini kukirim dua kali dalam satu detik". Kalau jawabannya "terjadi dua kali", aku belum boleh anggap selesai.
Takeaway
Webhook yang datang lebih dari sekali bukan skenario langka yang boleh diabaikan—dia perilaku yang memang didesain begitu oleh hampir semua payment gateway, dan sistem penerima yang wajib menjaga idempotency, bukan sebaliknya. Kalau kode menyentuh efek yang tidak boleh dobel, constraint unique di database jauh lebih bisa dipercaya daripada logika cek-lalu-tulis di level aplikasi, karena race condition selalu menunggu jendela waktu yang tepat untuk muncul—dan di payment, jendela itu berarti uang orang lain.
---
Sumber gambar: Chris Ried di Unsplash Sumber gambar: Glenn Carstens-Peters di Unsplash Sumber gambar: Tim van der Kuip di Unsplash
The conversation 0
No comments yet. Start the conversation.