Request POST yang diproses dua kali biasanya bukan karena endpoint "dipanggil iseng" oleh user, tetapi karena ada retry di salah satu lapisan: client mobile, SDK, reverse proxy, API gateway, load balancer, atau job worker. Jika backend tidak memakai idempotency key, satu niat bisnis seperti buat transaksi bisa dieksekusi lebih dari sekali.
Gejalanya sering menipu. Log aplikasi tampak normal, tidak ada error fatal, dan setiap request terlihat valid. Namun di produksi muncul transaksi dobel, order ganda, invoice duplikat, atau dua baris data dengan payload hampir sama. Inilah jenis bug yang sulit direproduksi karena akar masalahnya ada pada kombinasi timeout, retry, dan ketiadaan proteksi deduplikasi.
Mengapa bug ini berbahaya dan sulit terlihat
Pada endpoint baca seperti GET, retry biasanya aman. Pada endpoint tulis seperti POST, retry tanpa idempotency bisa mengubah satu kegagalan jaringan menjadi dua operasi bisnis yang berhasil. Secara teknis, skenario paling umum terlihat seperti ini:
- Client mengirim
POST /payments. - Backend mulai memproses dan berhasil menulis ke database.
- Sebelum respons diterima client, koneksi timeout atau terputus.
- Client atau perantara menganggap request gagal lalu melakukan retry.
- Backend menerima request kedua sebagai operasi baru dan membuat transaksi kedua.
Masalahnya bukan hanya pada client. Beberapa sistem internal juga melakukan retry otomatis:
- SDK HTTP dengan retry default untuk error tertentu.
- Proxy atau gateway yang mengulang upstream request saat koneksi diputus sebelum respons penuh terkirim.
- Queue worker yang menjalankan ulang job setelah timeout atau ack gagal.
- Cron atau orchestrator yang menganggap tugas sebelumnya gagal karena tidak mendapat sinyal selesai.
Catatan penting: retry sendiri bukan antipola. Retry sering diperlukan untuk meningkatkan keandalan. Yang berbahaya adalah retry pada operasi non-idempotent tanpa pengaman di sisi server.
Gejala produksi: log normal, data rusak
Di banyak insiden, gejala awal datang dari tim bisnis atau support, bukan dari alert teknis. Contohnya:
- Pelanggan ditagih dua kali dalam selang beberapa detik.
- Dua order identik muncul dengan customer, nominal, dan item yang sama.
- Webhook diproses dua kali lalu membuat status berputar.
- Stok berkurang dua kali untuk satu checkout.
Yang membuat frustrasi, log request bisa tampak normal:
- Dua request sama-sama punya status
200atau201. - Tidak ada stack trace.
- Masing-masing request punya durasi wajar.
- Payload valid, autentikasi valid, dan jalur kode biasa.
Jika tim hanya melihat log aplikasi per request, bug ini mudah disalahartikan sebagai user menekan tombol dua kali. Padahal sumbernya bisa berasal dari lapisan jaringan atau eksekusi asinkron.
Menyusun bukti insiden dengan pendekatan "evidence locker"
Untuk bug seperti ini, jangan langsung lompat ke hipotesis. Kumpulkan bukti seperti evidence locker: satu tempat terstruktur untuk semua artefak insiden. Tujuannya agar analisis tidak bias dan keputusan perbaikan bisa ditelusuri.
Apa saja yang perlu masuk ke evidence locker
- Timestamp presisi tinggi dari request, query database, enqueue/dequeue job, dan callback eksternal.
- Request ID / correlation ID per hop, termasuk header masuk dan header ke layanan downstream.
- Payload fingerprint, misalnya hash dari field bisnis penting seperti
user_id,amount,currency, danexternal_reference. - Audit trail database: kapan row dibuat, oleh proses mana, dan apakah ada constraint yang gagal atau justru tidak ada.
- Log retry dari client, gateway, job worker, atau library HTTP.
- Trace end-to-end jika observability stack mendukung distributed tracing.
Dengan pola ini, Anda tidak hanya bertanya "ada dua insert?" tetapi juga "apakah kedua insert itu berasal dari dua request berbeda, satu request yang diulang, atau satu job yang dieksekusi ulang?"
Root cause yang paling sering
1. Timeout terjadi setelah efek samping sudah terjadi
Ini skenario klasik. Backend sudah commit transaksi, tetapi client tidak menerima respons. Client mengirim ulang request yang sama. Tanpa idempotency key, backend tidak punya cara untuk tahu bahwa niat bisnis itu sudah pernah diproses.
2. Proxy atau gateway melakukan retry
Beberapa lapisan jaringan bisa mengulang request ketika koneksi ke upstream putus sebelum respons dianggap lengkap. Jika konfigurasi retry tidak dibatasi untuk metode yang aman, POST bisa ikut terulang.
3. Worker memproses job dua kali
Pada sistem asinkron, job bisa dijalankan ulang jika worker crash setelah melakukan efek samping tetapi sebelum menandai job selesai. Ini secara bentuk mirip dengan retry HTTP: satu niat bisnis, dua eksekusi.
4. Tidak ada unique constraint di level database
Walau aplikasi sudah mencoba mencegah duplikasi, tanpa constraint yang relevan database tetap bisa menerima dua baris identik secara bisnis. Kondisi balapan (race condition) sering lolos dari pemeriksaan check-then-insert biasa.
Alur kejadian insiden yang realistis
Misalkan ada endpoint pembayaran:
POST /payments
{
"user_id": "u123",
"order_id": "ord_789",
"amount": 150000
}Alurnya bisa seperti ini:
- API menerima request dan membuat record pembayaran.
- API memanggil penyedia pembayaran atau menulis ke beberapa tabel.
- Respons
201 Createdsedang dikirim. - Koneksi client putus atau timeout tepat sebelum respons selesai diterima.
- HTTP client punya kebijakan retry otomatis.
- Request yang sama masuk lagi dalam 2 detik.
- Karena tidak ada idempotency key dan tidak ada constraint unik pada kombinasi bisnis yang tepat, sistem membuat pembayaran kedua.
Dari sudut pandang log aplikasi, kedua request bisa terlihat "benar". Dari sudut pandang bisnis, ini insiden data.
Teknik investigasi: dari log sampai audit tabel
1. Cari pasangan request yang mirip
Mulai dari entitas yang terdampak, misalnya dua transaksi dengan nominal dan customer sama dalam rentang waktu pendek. Query audit sederhana sering membantu:
SELECT user_id, order_id, amount, COUNT(*) AS cnt,
MIN(created_at) AS first_seen,
MAX(created_at) AS last_seen
FROM payments
WHERE created_at >= NOW() - INTERVAL '1 day'
GROUP BY user_id, order_id, amount
HAVING COUNT(*) > 1
ORDER BY last_seen DESC;Query ini bukan solusi permanen, tetapi berguna untuk memetakan pola duplikasi.
2. Korelasikan dengan request ID dan trace ID
Pastikan setiap request masuk memiliki request_id. Jika sistem memanggil layanan lain, teruskan request_id atau gunakan trace_id yang konsisten di semua hop. Tujuannya:
- Mengetahui apakah dua insert berasal dari dua request HTTP terpisah.
- Mengetahui apakah request kedua muncul dari client yang sama.
- Mengetahui apakah ada retry di downstream yang memantul kembali ke sistem Anda.
Jika belum ada, ini sering menjadi perbaikan observability pertama yang paling murah dan paling berdampak.
3. Simpan metadata retry yang relevan
Jika memungkinkan, log informasi berikut:
- Header idempotency dari client.
- Attempt number jika client atau worker mengirimkannya.
- Status timeout, reset koneksi, atau upstream disconnect.
- Durasi request dan titik commit database.
Jangan log data sensitif mentah. Simpan referensi, hash, atau field yang aman untuk keperluan korelasi.
4. Periksa apakah bug berasal dari UI atau dari infrastruktur
Sering ada asumsi bahwa user menekan tombol dua kali. Verifikasi dulu:
- Apakah kedua request punya user agent dan sesi yang sama?
- Apakah selang waktunya terlalu dekat untuk interaksi manual?
- Apakah ada pola timeout di perangkat tertentu atau wilayah jaringan tertentu?
- Apakah gateway atau worker punya log retry pada waktu yang sama?
5. Bandingkan waktu commit dengan waktu respons
Jika commit database terjadi sebelum timeout jaringan, maka duplicate request sangat mungkin terjadi. Inilah bukti penting bahwa operasi pertama sebenarnya sukses walau sisi pemanggil menganggap gagal.
Reproduksi lokal yang masuk akal
Bug ini jarang stabil jika hanya diuji dengan klik manual. Reproduksi yang lebih efektif adalah mensimulasikan respons lambat atau putus koneksi setelah efek samping terjadi.
Pola reproduksi sederhana
- Buat endpoint uji yang menulis ke database lalu menunda respons beberapa detik.
- Set timeout client lebih pendek dari penundaan respons.
- Aktifkan retry otomatis pada client atau kirim request dua kali dengan payload sama.
- Amati apakah dua row tercipta.
Contoh pseudocode handler:
function createPayment(request) {
beginTransaction();
const payment = insertPayment({
userId: request.body.user_id,
orderId: request.body.order_id,
amount: request.body.amount
});
commitTransaction();
sleep(5000); // simulasi respons lambat / jaringan bermasalah
return response(201, { payment_id: payment.id });
}Lalu kirim dari client dengan timeout 2 detik dan retry 1 kali. Jika tidak ada idempotency, Anda biasanya akan melihat dua data.
Tip: jika ingin lebih dekat ke kondisi nyata, simulasikan pemutusan koneksi setelah commit, bukan hanya menunda sebelum menulis. Fokusnya adalah membuktikan bahwa efek samping bisa terjadi walau respons tidak sampai.
Perbaikan utama: idempotency key di endpoint tulis
Idempotency key adalah pengenal unik untuk satu niat operasi dari client. Misalnya, satu klik bayar menghasilkan satu key. Jika request yang sama dikirim ulang dengan key yang sama, server harus mengembalikan hasil yang sama atau menolak pembuatan operasi baru.
Cara kerja yang aman
- Client mengirim header seperti
Idempotency-Key: 6f8c.... - Server memeriksa apakah key itu sudah pernah diproses untuk konteks yang sama.
- Jika belum, server memproses request dan menyimpan hasilnya.
- Jika key yang sama datang lagi, server mengembalikan respons sebelumnya atau status yang menunjukkan operasi sudah pernah dilakukan.
Yang penting, key harus diikat ke konteks yang tepat: endpoint, tenant, user, atau kombinasi field bisnis yang relevan. Jangan menganggap satu key global tanpa namespace jika aplikasi multi-tenant.
Contoh alur penyimpanan idempotency
function handleCreatePayment(request) {
const idemKey = request.headers["idempotency-key"];
if (!idemKey) {
return response(400, { error: "missing idempotency key" });
}
const scope = `payments:${request.auth.userId}:${idemKey}`;
// Ambil record idempotensi dengan lock/atomic operation
let idem = idemStore.find(scope);
if (idem && idem.status === "completed") {
return response(idem.httpStatus, idem.responseBody);
}
if (!idem) {
idemStore.create({ scope, status: "processing", requestHash: hash(request.body) });
} else if (idem.requestHash !== hash(request.body)) {
return response(409, { error: "idempotency key reused with different payload" });
}
const payment = createPaymentInDatabase(request.body);
const result = { payment_id: payment.id, status: "created" };
idemStore.markCompleted(scope, 201, result);
return response(201, result);
}Poin penting pada implementasi di atas:
- Atomicity: pembuatan record idempotensi harus aman dari balapan.
- Request hash: key yang sama dengan payload berbeda harus dianggap konflik.
- Replay response: request duplikat sebaiknya menerima hasil konsisten, bukan membuat data baru.
Di mana menyimpan idempotency key
Pilihan yang umum:
- Database relasional: cocok jika butuh konsistensi kuat dan keterkaitan dengan transaksi bisnis.
- Redis: cocok untuk akses cepat, tetapi tetap perlu desain hati-hati untuk TTL, persistensi, dan konsistensi saat terjadi kegagalan.
Jika operasi benar-benar kritis seperti pembayaran, banyak tim memilih tetap punya perlindungan di database walau memakai cache untuk percepatan.
Perbaikan kedua: unique constraint sebagai pagar terakhir
Idempotency key bagus, tetapi jangan hanya bergantung pada aplikasi. Tambahkan unique constraint pada identitas bisnis yang benar jika domain memungkinkan. Contohnya, satu order_id hanya boleh punya satu transaksi tipe tertentu.
ALTER TABLE payments
ADD CONSTRAINT payments_unique_order UNIQUE (order_id);Atau jika kunci bisnis perlu kombinasi lebih spesifik:
ALTER TABLE payments
ADD CONSTRAINT payments_unique_business_key
UNIQUE (user_id, order_id, amount);Pilih kombinasi dengan hati-hati. Constraint yang terlalu sempit tidak melindungi apa-apa, tetapi constraint yang terlalu luas bisa menghalangi kasus bisnis yang sah seperti cicilan atau percobaan ulang yang memang harus tercatat berbeda.
Mengapa constraint penting
Tanpa constraint, dua proses paralel bisa sama-sama lolos dari logika "cek dulu baru insert". Database adalah tempat terbaik untuk menjaga integritas akhir karena ia melihat konflik pada saat penulisan, bukan pada asumsi sebelum penulisan.
Perbaikan ketiga: deduplication layer untuk konsumen event dan worker
Jika sumber duplikasi berasal dari queue atau event bus, tambahkan lapisan deduplikasi pada konsumen. Prinsipnya mirip dengan idempotency key, tetapi sumber key bisa berasal dari:
- ID event dari publisher.
- External reference dari sistem upstream.
- Fingerprint payload untuk event yang tidak punya ID stabil.
Simpan jejak event yang sudah diproses. Jika worker menerima ulang pesan yang sama, cukup ack tanpa mengulangi efek samping.
Trade-off-nya, deduplikasi berbasis fingerprint bisa rawan salah jika payload yang tampak sama ternyata punya makna bisnis berbeda. Lebih baik gunakan identifier yang memang dirancang unik dari sumber data.
Retry policy yang aman: bukan mematikan retry, tetapi membatasinya
Setelah bug ditemukan, reaksi yang sering muncul adalah mematikan semua retry. Itu kadang memperburuk reliabilitas. Pendekatan yang lebih tepat adalah retry yang sadar idempotency.
Prinsip umumnya
- Retry lebih aman untuk operasi yang memang idempotent.
- Untuk
POST, lakukan retry hanya jika ada idempotency key atau jaminan deduplikasi lain. - Gunakan exponential backoff dan batas percobaan yang masuk akal.
- Bedakan timeout koneksi, timeout baca, dan respons error aplikasi.
- Jangan menyamakan semua kegagalan sebagai kandidat retry.
Di sisi worker, pastikan timeout dan visibilitas pesan tidak menyebabkan dua worker memproses tugas yang sama terlalu cepat. Jika efek samping eksternal tidak bisa dihindari, catat external reference sebelum memanggil sistem luar agar percobaan ulang tetap bisa dikenali.
Observability setelah fix: pastikan masalah benar-benar hilang
Perbaikan belum selesai saat kode di-merge. Anda perlu memastikan sistem sekarang bisa membedakan request baru dan request ulang.
Metrik yang berguna
- Jumlah request dengan idempotency key.
- Jumlah replay dari idempotency store.
- Jumlah konflik key dengan payload berbeda.
- Jumlah pelanggaran unique constraint.
- Distribusi retry per client atau per endpoint.
Log yang perlu ditambahkan
request_id,trace_id, danidempotency_key.- Status idempotency:
new,processing,replayed,conflict. - Identitas bisnis seperti
order_idatauexternal_reference.
Dengan observability seperti ini, Anda bisa menjawab pertanyaan pascainsiden dengan cepat: apakah duplicate sekarang tertahan, apakah client tertentu masih sering timeout, dan apakah ada penyalahgunaan key yang sama untuk payload berbeda.
Kesalahan umum saat memperbaiki bug ini
- Hanya menambah debouncing di UI. Ini membantu untuk klik ganda manual, tetapi tidak menyelesaikan retry dari jaringan, proxy, atau worker.
- Hanya cek data sebelum insert. Ini rawan race condition jika tidak ada constraint atau lock yang benar.
- Menganggap request ID sama dengan idempotency key. Request ID biasanya unik per percobaan HTTP, sedangkan idempotency key mewakili satu niat bisnis lintas retry.
- Menyimpan idempotency key tanpa request hash. Akibatnya key yang sama bisa dipakai untuk payload berbeda tanpa terdeteksi.
- TTL terlalu pendek. Jika key kedaluwarsa terlalu cepat, retry terlambat tetap bisa membuat duplikasi.
Panduan implementasi yang praktis
Kapan wajib memakai idempotency key
- Pembayaran, refund, payout.
- Pembuatan order atau invoice.
- Webhook yang bisa dikirim ulang oleh provider.
- Operasi tulis apa pun yang punya dampak finansial atau stok.
Kapan unique constraint cukup membantu
- Jika domain punya identitas bisnis yang benar-benar unik, misalnya satu
external_referencedari mitra hanya boleh diproses sekali. - Jika Anda butuh pagar terakhir walau aplikasi atau cache gagal.
Kapan perlu keduanya
Untuk operasi kritis, jawabannya hampir selalu keduanya. Idempotency key mencegah duplikasi pada level API dan pengalaman client. Unique constraint menjaga integritas akhir saat ada race condition, deploy parsial, atau bug di lapisan aplikasi.
Penutup
Debug backend: request ganda dari retry HTTP tanpa idempotency key adalah kasus yang tampak sederhana tetapi sering berakar pada interaksi antar lapisan sistem. Satu request yang dianggap gagal oleh pemanggil bisa saja sudah sukses di server, lalu dieksekusi lagi saat retry terjadi.
Cara paling efektif menanganinya adalah menggabungkan tiga hal: kumpulkan bukti insiden secara disiplin, pasang idempotency key di endpoint tulis, dan tegakkan unique constraint atau deduplikasi di level penyimpanan/worker. Setelah itu, lengkapi dengan retry policy yang aman dan observability yang cukup. Dengan pendekatan ini, bug yang tadinya sulit direproduksi bisa dijelaskan, diperbaiki, dan dipantau secara sistematis.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!