Pada endpoint POST, masalah utamanya bukan hanya validasi input, tetapi juga duplikasi efek samping: transaksi tercatat dua kali, order ganda, saldo terpotong berulang, atau webhook diproses lebih dari sekali. Go Fiber: kontrak idempotency untuk POST API yang aman berarti server mampu menerima request yang sama lebih dari sekali dan tetap menghasilkan satu efek bisnis yang konsisten.
Solusi yang umum adalah Idempotency-Key: client mengirim kunci unik pada setiap operasi POST yang perlu aman saat retry. Server lalu mengikat kunci itu ke identitas pemanggil, route, fingerprint request, dan hasil respons pertama. Jika request yang sama datang lagi, server mengembalikan hasil yang sama, bukan mengeksekusi operasi baru.
Apa yang sebenarnya ingin dicegah?
Idempotency untuk POST dibutuhkan ketika request bisa terkirim lebih dari sekali karena hal-hal berikut:
- Client retry otomatis setelah timeout.
- Gangguan jaringan: server sudah memproses, tetapi client tidak menerima respons.
- Double submit dari UI, misalnya tombol diklik dua kali.
- Retry dari job queue atau webhook sender.
Tanpa kontrak yang jelas, retry bisa menimbulkan dua jenis masalah:
- Duplicate execution: operasi dijalankan ulang dan menciptakan data/transaksi baru.
- Ambiguous result: client tidak tahu request pertama berhasil atau tidak.
Idempotency tidak sama dengan deduplikasi global. Tujuannya adalah: untuk satu intent bisnis yang sama, request ulang tidak menghasilkan efek baru.
Kontrak API: header, scope, dan perilaku yang harus konsisten
1. Header Idempotency-Key
Gunakan header seperti:
POST /payments
Idempotency-Key: 01HV7M9R3J7F2Y5P8A1NQKZ6XE
Authorization: Bearer ...
Content-Type: application/jsonKunci ini sebaiknya:
- Dihasilkan oleh client.
- Unik untuk satu intent bisnis.
- Tidak dipakai ulang untuk operasi yang berbeda.
Formatnya bebas selama cukup unik, misalnya UUID atau ULID. Server sebaiknya memperlakukan nilainya sebagai string opaque, bukan mencoba menebak maknanya.
2. Scope key: jangan global tanpa konteks
Idempotency-Key jarang aman jika dianggap global. Scope yang lebih tepat biasanya gabungan dari:
- Identitas pemanggil (misalnya user_id, merchant_id, account_id, atau API key subject).
- Route atau operasi (misalnya
POST /paymentsberbeda dariPOST /orders).
Dengan begitu, key yang sama dari user berbeda tidak saling bertabrakan. Secara konseptual, kunci penyimpanan bisa dibentuk seperti:
scope = user_id + ":" + method + ":" + normalized_route + ":" + idempotency_keyKenapa route perlu masuk scope? Karena client bisa saja memakai generator key yang sama di beberapa endpoint. Tanpa scope route, request yang berbeda berpotensi dianggap replay yang sama.
3. Request fingerprint: payload sama atau berbeda?
Satu Idempotency-Key harus dikaitkan dengan fingerprint request. Ini penting untuk membedakan dua situasi:
- Replay yang sah: key sama, payload sama.
- Pelanggaran kontrak: key sama, payload berbeda.
Fingerprint umumnya dibuat dari data yang relevan secara bisnis, misalnya:
- HTTP method
- route
- user/tenant
- body request yang sudah dinormalisasi
Jika body JSON dipakai, hati-hati: string mentah bisa berbeda walau isinya sama karena urutan field atau whitespace. Pilihan yang lebih aman adalah melakukan canonicalization sederhana sebelum di-hash, atau membangun fingerprint dari field yang memang penting bagi operasi bisnis.
Jangan masukkan header yang berubah-ubah seperti
Dateatau token akses ke fingerprint. Itu akan membuat replay sah dianggap berbeda.
Status code yang tepat dan semantik respons
Kontrak idempotency akan lebih mudah diintegrasikan jika perilaku status code konsisten.
Kasus yang umum
- Request pertama berhasil diproses → kembalikan status normal, misalnya
201 Createdatau200 OK. - Replay dengan key dan fingerprint yang sama setelah sukses → kembalikan hasil pertama yang sama. Banyak sistem memilih tetap mengembalikan
200atau201beserta body yang sama. Yang terpenting adalah konsistensi. - Key sama, payload berbeda → kembalikan
409 Conflictkarena ada konflik terhadap kontrak idempotency. - Request kedua datang saat request pertama masih diproses → bisa kembalikan
409 Conflictatau425 Too Earlysecara konseptual, tetapi dalam praktik409lebih umum dan aman untuk interoperabilitas. - Header wajib tetapi tidak dikirim →
400 Bad Request.
Jika respons replay dikembalikan dari cache/record idempotency, Anda dapat menambahkan header diagnostik seperti:
Idempotency-Status: replay
Idempotency-Replayed: trueHeader ini tidak wajib, tetapi membantu debugging client dan observability.
Apa yang harus disimpan?
Minimal, server perlu menyimpan metadata idempotency berikut:
- scope key
- fingerprint request
- status pemrosesan:
processing,succeeded, ataufailed - status code respons awal
- body respons awal, jika ingin replay respons yang sama
- reference ke resource/transaksi yang dibuat
- waktu kedaluwarsa (TTL)
Ada dua strategi utama:
1. Simpan respons penuh
Kelebihan:
- Paling mudah untuk replay identik.
- Client menerima hasil yang konsisten.
Kekurangan:
- Penyimpanan lebih besar.
- Harus hati-hati jika body mengandung data sensitif.
2. Simpan referensi hasil bisnis
Misalnya simpan payment_id, lalu saat replay ambil resource itu lagi dan bentuk respons baru yang ekuivalen.
Kelebihan:
- Lebih hemat ruang.
- Lebih mudah jika respons bisa direkonstruksi secara stabil.
Kekurangan:
- Perlu memastikan representasi resource tetap kompatibel untuk replay.
- Bisa lebih kompleks jika respons awal berisi data transient.
Untuk endpoint transaksi kritikal, praktik yang aman adalah menyimpan metadata + referensi resource, dan jika memungkinkan juga body respons yang sudah disanitasi.
TTL: berapa lama record idempotency disimpan?
TTL bergantung pada pola retry client dan risiko bisnis. Prinsip umumnya:
- Cukup lama untuk menampung retry normal akibat timeout atau jaringan tidak stabil.
- Tidak terlalu lama sehingga storage penuh atau key lama menghambat operasi baru yang sebenarnya berbeda.
Pada banyak sistem, TTL diset dalam hitungan jam atau hari, bukan permanen. Untuk operasi keuangan, TTL sering dibuat lebih konservatif karena dampak duplikasi lebih mahal daripada biaya penyimpanan.
Hal penting: TTL adalah bagian kontrak. Jika key sudah kedaluwarsa, request dengan key yang sama dapat dianggap request baru. Ini harus dipahami oleh tim client.
Race condition: inti masalah di implementasi nyata
Masalah tersulit bukan menyimpan key, tetapi mencegah dua request paralel dengan key sama sama-sama lolos ke logika bisnis.
Pola yang aman
- Terima request dan validasi header
Idempotency-Key. - Bangun scope dan fingerprint.
- Lakukan claim atomik atas key: jika belum ada, tandai sebagai
processing. - Jika sudah ada record:
- fingerprint beda →
409 Conflict - status
processing→ tolak atau minta retry - status
succeeded→ replay hasil lama - Jalankan logika bisnis.
- Simpan hasil akhir ke record idempotency.
Kata kuncinya adalah atomik. Jangan cek "sudah ada atau belum" lalu insert di langkah terpisah tanpa proteksi, karena dua request paralel bisa sama-sama melihat data belum ada.
Opsi penyimpanan
Redis cocok untuk claim atomik cepat dengan TTL. Database relasional cocok jika Anda ingin konsistensi kuat dan bisa menyatukan record idempotency dengan transaksi bisnis.
Contoh skema Redis dan tabel database
Skema Redis
Satu key per operasi:
idem:{user_id}:{method}:{route}:{idempotency_key}Nilai bisa berupa JSON ringkas:
{
"fingerprint": "sha256:...",
"status": "processing",
"status_code": 0,
"response_body": "",
"resource_id": "",
"created_at": "2026-06-17T10:00:00Z"
}Pola umum:
- Claim awal dengan operasi atomik set-if-not-exists dan TTL.
- Finalisasi dengan update nilai ke
succeededdan memperpanjang TTL sesuai kebijakan.
Skema tabel SQL
CREATE TABLE api_idempotency (
scope_key VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
route VARCHAR(128) NOT NULL,
method VARCHAR(8) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
fingerprint VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL,
status_code INTEGER,
response_body TEXT,
resource_id VARCHAR(64),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL
);Jika tidak memakai scope_key sebagai primary key, minimal buat unique index pada kombinasi yang menjadi scope.
Di database relasional, unique constraint adalah fondasi untuk mencegah duplikasi claim.
Contoh alur request-response
Alur sukses pertama kali
- Client kirim
POST /paymentsdenganIdempotency-Key: K1. - Server membuat scope dan fingerprint.
- Record K1 belum ada, server claim status
processing. - Server membuat payment
pay_123. - Server menyimpan hasil:
succeeded, status code201, resource_idpay_123. - Client menerima
201 Created.
Alur replay setelah timeout di client
- Request pertama sebenarnya sukses, tetapi respons hilang di jaringan.
- Client retry dengan
Idempotency-Key: K1dan payload sama. - Server menemukan record
succeededdengan fingerprint yang sama. - Server mengembalikan hasil lama, tanpa membuat payment baru.
Alur key sama, payload berbeda
- Client kirim K1 dengan amount 100000.
- Lalu kirim lagi K1 dengan amount 150000.
- Fingerprint berbeda.
- Server kembalikan
409 Conflict.
Alur request paralel
- Dua request identik dengan K1 masuk hampir bersamaan.
- Hanya satu request berhasil claim key.
- Request lain melihat status
processing. - Server mengembalikan
409 Conflictatau respons retryable sesuai kontrak.
Implementasi praktis di Go Fiber
Di bawah ini contoh implementasi ringkas dengan pendekatan service-level. Contoh ini sengaja fokus pada kontrak dan alur, bukan pada driver Redis/DB tertentu.
Model record idempotency
type IdempotencyRecord struct {
ScopeKey string
Fingerprint string
Status string // processing, succeeded, failed
StatusCode int
ResponseBody []byte
ResourceID string
}
type IdempotencyStore interface {
Claim(scopeKey, fingerprint string, ttlSeconds int) (claimed bool, rec *IdempotencyRecord, err error)
MarkSucceeded(scopeKey string, statusCode int, body []byte, resourceID string, ttlSeconds int) error
MarkFailed(scopeKey string, statusCode int, body []byte, ttlSeconds int) error
Get(scopeKey string) (*IdempotencyRecord, error)
}Helper untuk scope dan fingerprint
func buildScopeKey(userID, method, route, idemKey string) string {
return userID + ":" + method + ":" + route + ":" + idemKey
}
func fingerprint(method, route, userID string, body []byte) string {
h := sha256.New()
h.Write([]byte(method))
h.Write([]byte("|"))
h.Write([]byte(route))
h.Write([]byte("|"))
h.Write([]byte(userID))
h.Write([]byte("|"))
h.Write(body)
return hex.EncodeToString(h.Sum(nil))
}Di sistem produksi, pertimbangkan normalisasi JSON sebelum hashing jika body JSON dapat memiliki variasi format.
Handler Fiber untuk POST yang idempotent
func CreatePaymentHandler(store IdempotencyStore) fiber.Handler {
return func(c *fiber.Ctx) error {
idemKey := c.Get("Idempotency-Key")
if idemKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "missing Idempotency-Key",
})
}
userID := c.Locals("user_id").(string)
route := "/payments"
body := c.Body()
fp := fingerprint(c.Method(), route, userID, body)
scope := buildScopeKey(userID, c.Method(), route, idemKey)
claimed, rec, err := store.Claim(scope, fp, 300)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "idempotency store error",
})
}
if !claimed {
if rec.Fingerprint != fp {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": "idempotency key already used with different payload",
})
}
switch rec.Status {
case "processing":
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": "request with same idempotency key is still processing",
})
case "succeeded", "failed":
c.Set("Idempotency-Status", "replay")
return c.Status(rec.StatusCode).Send(rec.ResponseBody)
default:
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "invalid idempotency state",
})
}
}
// Validasi payload bisnis
var req struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
if err := c.BodyParser(&req); err != nil {
resp := []byte(`{"error":"invalid json"}`)
_ = store.MarkFailed(scope, fiber.StatusBadRequest, resp, 300)
return c.Status(fiber.StatusBadRequest).Send(resp)
}
// Logika bisnis harus sebisa mungkin atomik dengan persistence utama.
paymentID, err := createPaymentInDB(userID, req.Amount, req.Currency)
if err != nil {
resp := []byte(`{"error":"unable to create payment"}`)
_ = store.MarkFailed(scope, fiber.StatusInternalServerError, resp, 300)
return c.Status(fiber.StatusInternalServerError).Send(resp)
}
resp := []byte(fmt.Sprintf(`{"id":"%s","status":"created"}`, paymentID))
if err := store.MarkSucceeded(scope, fiber.StatusCreated, resp, paymentID, 86400); err != nil {
// Operasi bisnis sudah sukses, tetapi pencatatan idempotency gagal.
// Jangan diamkan: log dan observability wajib ada.
}
return c.Status(fiber.StatusCreated).Send(resp)
}
}Contoh di atas menunjukkan beberapa prinsip penting:
- Header wajib divalidasi di awal.
- Claim dilakukan sebelum logika bisnis.
- Replay hanya sah jika fingerprint sama.
- Hasil pertama disimpan dan bisa diputar ulang.
Middleware atau handler?
Untuk Go Fiber, idempotency bisa ditempatkan di middleware, tetapi sering kali lebih praktis dikerjakan di level handler/service karena:
- Fingerprint kadang butuh pengetahuan payload bisnis.
- Hasil yang disimpan bisa berupa resource_id atau body yang dibentuk handler.
- Integrasi dengan transaksi database lebih mudah dikontrol dari service layer.
Middleware tetap berguna untuk validasi dasar, ekstraksi header, logging, atau menaruh konteks idempotency ke Locals. Namun keputusan final tentang apa yang dianggap “request yang sama” biasanya lebih aman ditaruh dekat logika domain.
Kasus sulit: kegagalan parsial dan transaksi bisnis
Edge case paling berbahaya adalah operasi bisnis sukses, tetapi penyimpanan record idempotency gagal. Misalnya payment berhasil dibuat di database, tetapi Redis sedang tidak tersedia saat MarkSucceeded.
Akibatnya, retry berikutnya bisa terlihat seperti request baru dan menciptakan duplikasi.
Cara mengurangi risiko
- Prioritaskan penyimpanan idempotency di database yang sama dengan transaksi bisnis jika memungkinkan.
- Jika memakai database relasional, pertimbangkan satu transaksi yang mencakup pembuatan resource dan finalisasi record idempotency.
- Jika memakai Redis terpisah, siapkan reconciliation atau cek berbasis business key tambahan.
Business key sebagai lapisan kedua
Untuk operasi kritikal, jangan hanya mengandalkan Idempotency-Key. Pertimbangkan juga unique constraint di level domain, misalnya:
external_referenceharus unik per merchantorder_numberharus unik per tenant
Dengan begitu, jika lapisan idempotency gagal, constraint bisnis masih dapat menahan duplikasi.
Payload sama vs berbeda: definisi harus eksplisit
Pertanyaan yang sering muncul: apa arti “payload sama”?
Jawabannya tergantung operasi. Untuk endpoint pembayaran, field berikut biasanya dianggap material:
- amount
- currency
- destination/source account
- external reference
Sedangkan field seperti header tracing atau metadata non-bisnis mungkin tidak perlu memengaruhi fingerprint. Jangan asal hash seluruh request tanpa berpikir; definisikan field mana yang menentukan intent bisnis.
Replay setelah sukses: kembalikan hasil lama, bukan hasil terbaru yang berubah
Jika request pertama sukses dan resource kemudian berubah status oleh proses lain, replay idempotency idealnya mengembalikan hasil awal yang terkait dengan operasi tersebut, bukan semata-mata state terbaru yang bisa berbeda konteks.
Contoh: payment dibuat dengan status pending, lalu beberapa detik kemudian berubah menjadi settled oleh worker async. Jika replay terjadi segera setelah pembuatan, sering kali lebih masuk akal mengembalikan hasil pembuatan awal yang sama dengan request pertama.
Trade-off-nya:
- Simpan respons awal → replay lebih konsisten terhadap kontrak.
- Rebuild dari resource terbaru → lebih sederhana, tetapi bisa membingungkan client.
Pilih salah satu dan dokumentasikan dengan jelas.
Observability dan debugging
Tanpa observability, bug idempotency sulit dilacak karena biasanya muncul saat timeout, retry, dan kondisi paralel.
Minimal catat:
- idempotency_key
- scope_key
- fingerprint
- status record saat request masuk
- hasil claim: claimed atau replay
- resource_id yang dibuat
Tambahkan metrik seperti:
- jumlah replay sukses
- jumlah conflict karena fingerprint berbeda
- jumlah request yang mentok di status
processing - kegagalan finalisasi record idempotency
Jika ada record processing yang tidak pernah selesai karena crash, siapkan strategi pemulihan. Misalnya, status processing kedaluwarsa setelah waktu tertentu dan request berikutnya akan melakukan evaluasi ulang dengan hati-hati.
Kesalahan umum integrasi
- Menganggap idempotency key global tanpa scope user/route.
- Tidak menyimpan fingerprint, sehingga key yang sama dengan payload berbeda tetap dianggap replay sah.
- Cek lalu insert tanpa atomisitas, yang membuka race condition.
- Hanya menyimpan key, bukan hasil, sehingga replay tidak bisa mengembalikan respons yang konsisten.
- TTL terlalu pendek, sehingga retry normal setelah timeout malah diperlakukan sebagai request baru.
- Tidak memikirkan kegagalan parsial antara operasi bisnis dan penyimpanan record idempotency.
- Menghash body mentah JSON tanpa normalisasi saat variasi format mungkin terjadi.
- Mengembalikan status code yang berubah-ubah sehingga client sulit membedakan replay, conflict, dan request baru.
Checklist implementasi
- Tentukan endpoint POST mana yang wajib memakai
Idempotency-Key. - Definisikan format header dan dokumentasikan bahwa key dibuat oleh client.
- Tentukan scope: minimal user/tenant + method + route + key.
- Tentukan fingerprint berdasarkan field bisnis yang material.
- Implementasikan claim atomik untuk mencegah dua request paralel lolos bersamaan.
- Simpan status
processing,succeeded, dan jika perlufailed. - Simpan status code dan body respons awal, atau referensi resource yang cukup untuk replay.
- Tetapkan aturan
409 Conflictuntuk key sama dengan payload berbeda. - Tentukan TTL yang sesuai dengan pola retry dan risiko bisnis.
- Rancang penanganan kegagalan parsial antara persistence utama dan store idempotency.
- Tambahkan logging, tracing, dan metrik replay/conflict.
- Uji skenario: retry setelah timeout, double submit, request paralel, payload berbeda, dan replay setelah sukses.
Penutup
Go Fiber: kontrak idempotency untuk POST API yang aman bukan sekadar menambahkan header, tetapi merancang perilaku server yang konsisten saat request diulang. Kunci keberhasilannya ada pada empat hal: scope yang benar, fingerprint request, penyimpanan hasil awal, dan claim atomik untuk mencegah race condition.
Jika endpoint Anda membuat transaksi, order, atau perubahan state yang mahal untuk dibatalkan, idempotency sebaiknya dianggap bagian dari kontrak API inti, bukan fitur tambahan. Dokumentasikan perilakunya dengan jelas, uji pada kondisi retry dan timeout, lalu pastikan implementasinya tahan terhadap kegagalan parsial.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!