Endpoint idempoten di Go: kunci, tabel dedup, dan retry
Rancang endpoint idempoten di Go dengan kunci idempoten, tabel dedup, dan penanganan yang aman terhadap retry untuk pembayaran, impor, dan webhook.

Mengapa retry membuat duplikat (dan kenapa idempoten penting)
Retry terjadi bahkan ketika tidak ada yang “salah.” Klien mengalami timeout sementara server masih memproses. Koneksi seluler putus dan aplikasi mencoba lagi. Job runner menerima 502 dan otomatis mengirim ulang permintaan yang sama. Dengan pengiriman minimal-sekali (at-least-once) — umum pada antrean dan webhook — duplikat adalah hal biasa.
Itu sebabnya idempoten penting: permintaan berulang harus menghasilkan hasil akhir yang sama seperti satu permintaan.
Beberapa istilah mudah tertukar:
- Safe: memanggilnya tidak mengubah state (seperti read).
- Idempotent: memanggilnya berkali-kali punya efek yang sama seperti memanggilnya sekali.
- At-least-once: pengirim mengulang sampai “menempel,” jadi penerima harus menangani duplikat.
Tanpa idempoten, retry bisa menyebabkan kerusakan nyata. Endpoint pembayaran bisa menagih dua kali jika charge pertama berhasil tapi respons tidak sampai ke klien. Endpoint impor bisa membuat baris duplikat ketika worker retry setelah timeout. Handler webhook bisa memproses event yang sama dua kali dan mengirim dua email.
Intinya: idempoten adalah kontrak API, bukan detail implementasi pribadi. Klien perlu tahu apa yang boleh di-retry, kunci apa yang harus dikirim, dan respons apa yang bisa diharapkan ketika duplikat terdeteksi. Jika Anda mengubah perilaku secara diam-diam, Anda mematahkan logika retry dan menciptakan mode kegagalan baru.
Idempoten juga bukan pengganti monitoring dan rekonsiliasi. Lacak laju duplikat, catat keputusan “replay,” dan bandingkan periodik dengan sistem eksternal (mis. penyedia pembayaran) terhadap database Anda.
Tentukan scope idempoten dan aturan untuk tiap endpoint
Sebelum menambahkan tabel atau middleware, tentukan apa arti “permintaan yang sama” dan janji server saat klien melakukan retry.
Sebagian besar masalah muncul pada POST karena biasanya membuat sesuatu atau memicu side effect (menagih kartu, mengirim pesan, memulai impor). PATCH juga bisa butuh idempoten jika memicu side effect, bukan hanya update field sederhana. GET tidak boleh mengubah state.
Definisikan scope: di mana sebuah kunci bersifat unik
Pilih scope yang cocok dengan aturan bisnis Anda. Terlalu luas memblokir kerja yang valid. Terlalu sempit membiarkan duplikat.
Scope umum:
- Per endpoint + customer
- Per endpoint + objek eksternal (mis. invoice_id atau order_id)
- Per endpoint + tenant (untuk sistem multi-tenant)
- Per endpoint + metode pembayaran + jumlah (hanya jika aturan produk Anda mengizinkan)
Contoh: untuk endpoint “Create payment,” buat kunci unik per customer. Untuk “Ingest webhook event,” scope ke payment provider event ID (unik secara global dari provider).
Tentukan apa yang diulang pada duplikat
Saat duplikat datang, kembalikan hasil yang sama seperti percobaan pertama yang berhasil. Dalam praktiknya, itu berarti me-replay kode status HTTP yang sama dan body respons yang sama (atau setidaknya ID resource dan status yang sama).
Klien bergantung pada ini. Jika percobaan pertama berhasil tetapi jaringan terputus, retry tidak boleh membuat charge kedua atau job impor kedua.
Pilih window retensi
Kunci harus kedaluwarsa. Simpan cukup lama untuk menutupi retry realistis dan job yang tertunda.
- Pembayaran: 24 sampai 72 jam umum.
- Impor: seminggu bisa masuk akal jika pengguna mungkin retry kemudian.
- Webhook: cocokkan dengan kebijakan retry provider.
Definisikan “permintaan yang sama”: kunci eksplisit vs hash body
Kunci idempoten eksplisit (header atau field) biasanya aturan paling bersih.
Hash body bisa membantu sebagai backstop, tapi mudah rusak dengan perubahan sepele (urutan field, whitespace, timestamp). Jika menggunakan hashing, normalisasi input dan tegas tentang field yang disertakan.
Kunci idempoten: bagaimana bekerja dalam praktik
Kunci idempoten adalah kontrak sederhana antara klien dan server: “Jika Anda melihat kunci ini lagi, perlakukan sebagai permintaan yang sama.” Ini salah satu alat paling praktis untuk API yang aman terhadap retry.
Kunci bisa datang dari salah satu sisi, tetapi untuk sebagian besar API sebaiknya digenerasi klien. Klien tahu kapan sedang mengulang aksi yang sama, jadi bisa menggunakan kembali kunci yang sama di seluruh percobaan. Kunci yang digenerasi server membantu ketika Anda pertama kali membuat resource “draft” (mis. job impor) dan kemudian membiarkan klien retry dengan merujuk ID job itu, tetapi tidak membantu untuk permintaan pertama.
Gunakan string acak yang tidak bisa ditebak. Usahakan setidaknya 128 bit randomness (mis. 32 hex char atau UUID). Jangan buat kunci dari timestamp atau user ID.
Di server, simpan kunci beserta konteks yang cukup untuk mendeteksi penyalahgunaan dan me-replay hasil asli:
- Siapa yang membuat panggilan (account atau user ID)
- Endpoint atau operasi yang relevan
- Hash dari field permintaan penting
- Status saat ini (in-progress, succeeded, failed)
- Respons untuk di-replay (kode status dan body)
Kunci sebaiknya di-scope, biasanya per user (atau per API token) plus endpoint. Jika kunci yang sama dipakai dengan payload berbeda, tolak dengan error yang jelas. Itu mencegah tabrakan tidak sengaja di mana klien buggy mengirim jumlah pembayaran baru menggunakan kunci lama.
Pada replay, kembalikan hasil yang sama seperti percobaan pertama yang berhasil. Itu berarti kode status HTTP dan body respons yang sama, bukan pembacaan baru yang mungkin telah berubah.
Tabel dedup di PostgreSQL: pola sederhana dan andal
Tabel deduplikasi khusus adalah salah satu cara paling sederhana untuk menerapkan idempoten. Permintaan pertama membuat baris untuk kunci idempoten. Semua retry membaca baris yang sama dan mengembalikan hasil yang tersimpan.
Apa yang disimpan
Jaga tabel tetap kecil dan fokus. Struktur umum:
key: kunci idempoten (text)owner: siapa pemilik kunci (user_id, account_id, atau API client ID)request_hash: hash dari field permintaan pentingresponse: payload respons akhir (sering JSON) atau pointer ke hasil tersimpancreated_at: kapan kunci pertama kali terlihat
Constraint unik adalah inti pola. Tegakkan keunikan pada (owner, key) sehingga satu klien tidak bisa membuat duplikat, dan dua klien berbeda tidak bertabrakan.
Juga simpan request_hash agar Anda bisa mendeteksi penyalahgunaan kunci. Jika retry datang dengan kunci yang sama tapi hash berbeda, kembalikan error daripada mencampur dua operasi berbeda.
Retensi dan indexing
Baris dedup tidak boleh hidup selamanya. Simpan cukup lama untuk menutup window retry, lalu bersihkan.
Untuk kecepatan saat beban tinggi:
- Index unik pada
(owner, key)untuk insert atau lookup cepat - Index opsional pada
created_atuntuk pembersihan murah
Jika respons besar, simpan pointer (mis. result ID) dan tempatkan payload penuh di luar tabel. Itu mengurangi pembesaran tabel sambil menjaga perilaku retry konsisten.
Langkah demi langkah: alur handler yang aman terhadap retry di Go
Handler yang aman terhadap retry butuh dua hal: cara stabil untuk mengidentifikasi “permintaan yang sama lagi,” dan tempat yang tahan lama untuk menyimpan hasil pertama supaya Anda bisa me-replay-nya.
Alur praktis untuk pembayaran, impor, dan ingest webhook:
-
Validasi permintaan, lalu turunkan tiga nilai: idempotency key (dari header atau field klien), owner (tenant atau user ID), dan request hash (hash dari field penting).
-
Mulai transaksi database dan coba buat record dedup. Buat unik pada
(owner, key). Simpanrequest_hash, status (started, completed), dan placeholder untuk respons. -
Jika insert konflik, muat baris yang ada. Jika sudah completed, kembalikan respons tersimpan. Jika started, tunggu sebentar (polling sederhana) atau kembalikan 409/202 supaya klien retry nanti.
-
Hanya ketika Anda berhasil “menguasai” baris dedup, jalankan logika bisnis sekali. Tulis side effect di dalam transaksi yang sama bila memungkinkan. Persist hasil bisnis plus respons HTTP (kode status dan body).
-
Commit, dan catat bersama idempotency key dan owner agar dukungan bisa menelusuri duplikat.
Pola tabel minimal:
create table idempotency_keys (
owner_id text not null,
idem_key text not null,
request_hash text not null,
status text not null,
response_code int,
response_body jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (owner_id, idem_key)
);
Contoh: endpoint “Create payout” timeout setelah mengenakan biaya. Klien retry dengan kunci yang sama. Handler Anda menemui konflik, melihat record completed, dan mengembalikan payout ID asli tanpa menagih lagi.
Pembayaran: charge sekali saja, bahkan dengan timeout
Pembayaran adalah tempat idempoten menjadi wajib. Jaringan gagal, aplikasi seluler retry, dan gateway kadang timeout setelah mereka sudah membuat charge.
Aturan praktis: kunci idempoten menjaga pembuatan charge, dan provider ID pembayaran (charge/intent ID) menjadi sumber kebenaran setelah itu. Setelah Anda menyimpan provider ID, jangan buat charge baru untuk permintaan yang sama.
Pola yang menangani retry dan ketidakpastian gateway:
- Baca dan validasi idempotency key.
- Dalam transaksi database, buat atau ambil baris pembayaran yang di-key oleh
(merchant_id, idempotency_key). Jika sudah adaprovider_id, kembalikan hasil tersimpan. - Jika belum ada
provider_id, panggil gateway untuk membuat PaymentIntent/Charge. - Jika gateway berhasil, simpan
provider_iddan tandai pembayaran sebagai “succeeded” (atau “requires_action”). - Jika gateway timeout atau mengembalikan hasil tidak diketahui, simpan status “pending” dan kembalikan respons konsisten yang memberi tahu klien aman untuk retry.
Detail kunci adalah bagaimana Anda memperlakukan timeout: jangan anggap gagal. Tandai pembayaran sebagai pending, lalu konfirmasi dengan menanyakan gateway nanti (atau via webhook) menggunakan provider ID sekali Anda memilikinya.
Respons error harus dapat diprediksi. Klien membangun logika retry berdasarkan apa yang Anda kembalikan, jadi pertahankan kode status dan bentuk error yang stabil.
Impor dan endpoint batch: dedup tanpa kehilangan progres
Impor adalah tempat duplikat paling merugikan. Pengguna mengunggah CSV, server timeout pada 95%, dan mereka retry. Tanpa rencana, Anda entah membuat baris duplikat atau memaksa mereka mulai ulang.
Untuk pekerjaan batch, pikirkan dalam dua lapis: job impor dan item di dalamnya. Idempoten di level job menghentikan permintaan yang sama membuat banyak job. Idempoten di level item menghentikan baris yang sama diterapkan dua kali.
Pola level job adalah meminta idempotency key per permintaan impor (atau turun dari hash request stabil plus user ID). Simpan bersamanya record import_job dan kembalikan job ID yang sama saat retry. Handler harus bisa mengatakan, “Saya sudah melihat job ini, ini statusnya saat ini,” alih-alih “mulai lagi.”
Untuk dedup di level item, andalkan natural key yang sudah ada di data. Misalnya setiap baris mungkin menyertakan external_id dari sistem sumber, atau kombinasi stabil seperti (account_id, email). Tegakkan dengan unique constraint di PostgreSQL dan gunakan perilaku upsert sehingga retry tidak membuat duplikat.
Sebelum rilis, tentukan apa yang dilakukan replay ketika baris sudah ada. Jelaskan: skip, update field tertentu, atau gagal. Hindari “merge” kecuali Anda punya aturan yang sangat jelas.
Partial success itu normal. Daripada mengembalikan satu “ok” atau “failed,” simpan outcome per baris terkait job: nomor baris, natural key, status (created, updated, skipped, error), dan pesan error. Saat retry, Anda dapat menjalankan ulang dengan aman sambil mempertahankan hasil untuk baris yang sudah selesai.
Untuk membuat impor dapat di-restart, tambahkan checkpoint. Proses secara halaman (mis. 500 baris per halaman), simpan cursor terakhir yang diproses (index baris atau source cursor), dan update setelah setiap halaman commit. Jika proses crash, upaya berikutnya melanjutkan dari checkpoint terakhir.
Ingest webhook: dedup, validasi, lalu proses dengan aman
Pengirim webhook melakukan retry. Mereka juga mengirim event out of order. Jika handler Anda memperbarui state pada setiap delivery, akhirnya Anda akan menggandakan pembuatan record, pengiriman email, atau penagihan.
Mulailah dengan memilih kunci dedup terbaik. Jika provider memberi Anda event ID unik, gunakan itu. Hanya gunakan hash payload jika tidak ada event ID.
Keamanan nomor satu: verifikasi signature sebelum menerima apa pun. Jika signature gagal, tolak permintaan dan jangan tulis record dedup. Jika tidak, seorang penyerang bisa “mencadangkan” event ID dan memblokir event asli nanti.
Alur aman di bawah retry:
- Verifikasi signature dan bentuk dasar (header yang diperlukan, event ID).
- Insert event ID ke tabel dedup dengan constraint unik.
- Jika insert gagal karena duplikat, kembalikan 200 segera.
- Simpan payload mentah (dan header) bila berguna untuk audit dan debugging.
- Enqueue pemrosesan dan kembalikan 200 dengan cepat.
Acknowledge cepat penting karena banyak provider punya timeout singkat. Lakukan pekerjaan terkecil yang andal dalam request: verifikasi, dedup, persist. Lalu proses secara asinkron (worker, queue, background job). Jika Anda tidak bisa asinkron, jaga proses idempoten dengan memberi key yang sama pada side effect internal.
Out-of-order delivery itu normal. Jangan mengasumsikan “created” datang sebelum “updated.” Lebih suka upsert berdasarkan external object ID dan lacak timestamp atau versi event terakhir yang telah diproses.
Menyimpan payload mentah membantu ketika pelanggan berkata “kami tidak menerima update.” Anda bisa menjalankan ulang pemrosesan dari body yang tersimpan setelah memperbaiki bug, tanpa meminta provider mengirim ulang.
Konkruensi: tetap benar di bawah permintaan paralel
Retry menjadi rumit ketika dua permintaan dengan kunci idempoten yang sama tiba bersamaan. Jika kedua handler menjalankan langkah “do work” sebelum salah satu menyimpan hasil, Anda masih bisa menggandakan charge, impor, atau enqueue.
Titik koordinasi paling sederhana adalah transaksi database. Jadikan langkah pertama “klaim kunci” dan biarkan database memutuskan siapa yang menang. Opsi umum:
- Insert unik ke tabel dedup (database menegakkan satu pemenang)
SELECT ... FOR UPDATEsetelah membuat (atau menemukan) baris dedup- Advisory lock level transaksi yang diberi kunci dari hash idempotency key
- Unique constraint pada record bisnis sebagai backstop akhir
Untuk pekerjaan yang lama, hindari menahan row lock sembari memanggil sistem eksternal atau menjalankan impor berjam-jam. Sebaliknya, simpan state machine kecil di baris dedup agar permintaan lain bisa keluar cepat.
Set status yang praktis:
in_progressdenganstarted_atcompleteddengan respons cachedfaileddengan kode error (opsional, tergantung kebijakan retry)expires_at(untuk pembersihan)
Contoh: dua instance aplikasi menerima permintaan pembayaran yang sama. Instance A insert kunci dan tandai in_progress, lalu panggil provider. Instance B menemui path konflik, membaca baris dedup, melihat in_progress, dan mengembalikan respons “masih diproses” cepat (atau menunggu sebentar dan memeriksa ulang). Ketika A selesai, ia mengupdate baris ke completed dan menyimpan body respons sehingga retry berikutnya mendapat output yang persis sama.
Kesalahan umum yang merusak idempoten
Kebanyakan bug idempoten bukan soal locking rumit. Mereka adalah pilihan “hampir benar” yang gagal di bawah retry, timeout, atau dua pengguna melakukan aksi serupa.
Perangkap umum adalah memperlakukan idempotency key sebagai unik secara global. Jika Anda tidak menscope (berdasarkan user, account, atau endpoint), dua klien berbeda bisa bertabrakan dan satu akan mendapatkan hasil milik yang lain.
Masalah lain adalah menerima kunci yang sama dengan body permintaan berbeda. Jika panggilan pertama untuk $10 dan replay untuk $100, Anda tidak boleh secara diam-diam mengembalikan hasil pertama. Simpan request hash (atau field kunci), bandingkan saat replay, dan kembalikan error konflik jelas.
Klien juga bingung ketika replay mengembalikan bentuk respons atau kode status yang berbeda. Jika panggilan pertama mengembalikan 201 dengan body JSON, replay harus mengembalikan body yang sama dan kode status yang konsisten. Mengubah perilaku replay memaksa klien menebak.
Kesalahan yang sering menyebabkan duplikat:
- Mengandalkan hanya pada map atau cache in-memory, lalu kehilangan state dedup saat restart.
- Menggunakan kunci tanpa scope (tabrakan lintas-user atau lintas-endpoint).
- Tidak memvalidasi mismatch payload untuk kunci yang sama.
- Menjalankan side effect terlebih dulu (charge, insert, publish) dan menulis record dedup setelahnya.
- Mengembalikan ID baru pada setiap retry daripada me-replay hasil asli.
Cache dapat mempercepat pembacaan, tetapi sumber kebenaran harus tahan lama (biasanya PostgreSQL). Kalau tidak, retry setelah deploy bisa membuat duplikat.
Rencanakan juga pembersihan. Jika Anda menyimpan setiap kunci selamanya, tabel membesar dan index melambat. Tetapkan window retensi berdasarkan perilaku retry nyata, hapus baris lama, dan jaga index unik tetap kecil.
Daftar periksa cepat dan langkah selanjutnya
Anggap idempoten sebagai bagian dari kontrak API Anda. Setiap endpoint yang mungkin di-retry oleh klien, antrean, atau gateway perlu aturan jelas tentang apa arti “permintaan yang sama” dan apa bentuk “hasil yang sama.”
Daftar periksa sebelum rilis:
- Untuk tiap endpoint yang dapat di-retry, apakah scope idempoten didefinisikan (per user, per account, per order, per event eksternal) dan didokumentasikan?
- Apakah dedup ditegakkan oleh database (unique constraint pada idempoten key dan scope), bukan hanya “diperiksa di kode”?
- Saat replay, apakah Anda mengembalikan kode status dan body respons yang sama (atau subset stabil yang didokumentasikan), bukan objek segar atau timestamp baru?
- Untuk pembayaran, apakah Anda menangani hasil yang tidak diketahui dengan aman (timeout setelah submit, gateway mengatakan “processing”) tanpa menagih dua kali?
- Apakah log dan metrik membuat jelas kapan permintaan pertama-kali terlihat vs di-replay?
Jika ada item yang “mungkin,” perbaiki sekarang. Sebagian besar kegagalan muncul di bawah tekanan: retry paralel, jaringan lambat, dan gangguan parsial.
Jika Anda membangun alat internal atau aplikasi pelanggan di AppMaster (appmaster.io), ada baiknya merancang kunci idempoten dan tabel dedup PostgreSQL lebih awal. Dengan begitu, meski platform meregenerasi kode backend Go saat kebutuhan berubah, perilaku retry Anda tetap konsisten.
FAQ
Retry itu normal karena jaringan dan klien gagal dalam cara yang biasa terjadi. Permintaan bisa berhasil di server tetapi respons tidak sampai ke klien, sehingga klien mengirim ulang dan Anda melakukan pekerjaan yang sama dua kali kecuali server bisa mengenali dan me-replay hasil asli.
Kirim kunci yang sama pada setiap retry untuk aksi yang sama. Hasilkan di sisi klien sebagai string acak yang tidak mudah ditebak (misalnya UUID), dan jangan gunakan kembali untuk aksi berbeda.
Scope-kan kunci sesuai aturan bisnis Anda, biasanya per endpoint ditambah identitas pemanggil seperti user, account, tenant, atau token API. Ini mencegah dua pelanggan berbeda bertabrakan pada kunci yang sama dan menerima hasil satu sama lain.
Kembalikan hasil yang sama seperti upaya pertama yang berhasil. Praktiknya, replay kode status HTTP yang sama dan body respons yang sama, atau setidaknya ID resource dan status yang sama, sehingga klien bisa retry tanpa memicu side effect kedua.
Tolak dengan error konflik yang jelas daripada menebak. Simpan dan bandingkan hash dari bidang permintaan penting, dan jika kunci cocok tetapi payload berbeda, gagal dengan cepat untuk menghindari mencampur dua operasi berbeda di bawah satu kunci.
Simpan kunci cukup lama untuk menutupi retry realistis, lalu hapus. Default umum: 24–72 jam untuk pembayaran, sekitar seminggu untuk impor, dan untuk webhook cocokkan dengan kebijakan retry pengirim agar retry terlambat masih terdeduplikasi.
Tabel dedup khusus bekerja baik karena database bisa menegakkan constraint unik dan bertahan saat restart. Simpan scope owner, kunci, hash permintaan, status, dan respons untuk di-replay, lalu buat (owner, key) unik sehingga hanya satu permintaan yang “menang.”
Klaim kunci di dalam transaksi database terlebih dulu, lalu lakukan side effect hanya jika Anda berhasil mengklaimnya. Jika permintaan lain datang paralel, ia harus menemui constraint unik, melihat in_progress atau completed, dan mengembalikan respons tunggu/replay daripada menjalankan logika dua kali.
Anggap timeout sebagai “tidak diketahui”, bukan “gagal”. Catat status pending dan, jika Anda punya provider ID, gunakan itu sebagai sumber kebenaran supaya retry mengembalikan hasil pembayaran yang sama alih-alih membuat charge baru.
Deduplikasi di dua level: level job dan level item. Buat retry mengembalikan job ID impor yang sama, dan terapkan natural key untuk baris (mis. external ID atau (account_id, email)) dengan unique constraint atau upsert sehingga reprocessing tidak menciptakan duplikat.


