Desain API idempotent untuk POST create diperlukan saat klien bisa mengirim ulang request yang sama karena timeout, koneksi putus, retry otomatis dari SDK, atau pengguna menekan tombol submit dua kali. Tanpa kontrak yang jelas, satu aksi bisnis dapat membuat dua order, dua pembayaran, atau dua record yang seharusnya hanya dibuat sekali.
Solusi yang umum dipakai adalah Idempotency-Key: klien mengirim kunci unik per aksi create, lalu server menyimpan hasil request pertama dan mengembalikan hasil yang sama untuk request duplikat. Tantangannya bukan hanya menyimpan key, tetapi juga menentukan scope key, masa simpan, perilaku saat payload berubah, status code yang konsisten, dan cara mencegah race condition saat dua request identik datang hampir bersamaan.
Kapan POST create perlu Idempotency-Key
Tidak semua endpoint POST membutuhkan idempotensi tingkat aplikasi. Namun untuk endpoint yang menghasilkan efek samping penting, terutama yang melibatkan uang, stok, order, atau sumber daya yang mahal, Idempotency-Key sangat dianjurkan.
Kasus yang layak menggunakan idempotensi
- Payment initiation: satu retry tidak boleh membuat dua charge.
- Order creation: refresh halaman atau double click tidak boleh membuat order ganda.
- Provisioning resource: misalnya membuat subscription, invoice, tiket, atau reservasi.
- Integrasi dengan klien mobile yang sering terkena jaringan tidak stabil.
Kapan tidak wajib
- POST yang hanya memicu proses non-kritis dan aman diduplikasi.
- Endpoint internal yang sudah punya jaminan deduplikasi kuat di layer lain.
- Create yang sebenarnya bisa dimodelkan sebagai PUT /resources/{client_generated_id}.
Jika klien mungkin melakukan retry, dan efek samping endpoint tidak boleh terjadi lebih dari sekali, anggap idempotensi sebagai bagian dari kontrak API, bukan fitur tambahan.
Kontrak API yang jelas: header, scope, TTL, dan perilaku respons
Format header
Gunakan header yang eksplisit, misalnya:
Idempotency-Key: 8f6d7d2e-6c1a-4b8f-a0b9-0f2c9d1a7e31Nilainya sebaiknya unik, sulit ditebak, dan dibuat oleh klien per operasi bisnis. UUID acak biasanya cukup. Jangan membuat key dari timestamp saja karena rawan tabrakan dan sulit dilacak.
Scope key: jangan global tanpa konteks
Salah satu kesalahan paling umum adalah memperlakukan Idempotency-Key sebagai unik secara global tanpa konteks request. Scope yang lebih aman biasanya mencakup:
- Tenant atau user: agar key yang sama dari dua user berbeda tidak berbenturan.
- Route atau operasi: misalnya
POST /paymentsdibedakan dariPOST /orders. - Metode HTTP bila diperlukan.
Dengan demikian, kunci penyimpanan idealnya bukan hanya nilai header, tetapi kombinasi seperti:
{principal_id}:{http_method}:{route_pattern}:{idempotency_key}Contoh:
user_123:POST:/v1/orders:8f6d7d2e-6c1a-4b8f-a0b9-0f2c9d1a7e31Pendekatan ini mencegah benturan antar pengguna dan memperjelas bahwa satu key berlaku untuk satu aksi tertentu, bukan untuk seluruh sistem.
Masa simpan key
Idempotency-Key tidak perlu disimpan selamanya. Simpan selama jendela retry yang realistis. Tujuannya agar klien masih bisa mengulang request setelah timeout, tetapi storage tidak tumbuh tanpa batas.
Prinsip umum:
- Untuk operasi pembayaran atau order, TTL umumnya disesuaikan dengan pola retry klien dan SLA jaringan.
- Jangan terlalu singkat; jika TTL habis sebelum klien retry, request duplikat bisa diproses sebagai request baru.
- Jangan terlalu lama tanpa alasan; storage akan membengkak dan konflik historis jadi sulit dipahami.
Jika durasi tepatnya belum pasti, pilih konservatif berdasarkan perilaku retry aktual dan dokumentasikan secara eksplisit ke klien.
Payload sama vs berbeda
Ini bagian yang wajib didefinisikan. Untuk key yang sama, server harus membedakan dua kondisi:
- Payload sama: dianggap retry dari operasi yang sama. Server mengembalikan hasil yang sudah tersimpan.
- Payload berbeda: dianggap konflik, karena klien memakai key yang sama untuk aksi berbeda.
Untuk membandingkan payload, simpan fingerprint dari request yang relevan, misalnya hash dari body yang sudah dinormalisasi. Jika body mengandung field non-deterministik seperti urutan properti JSON yang bisa berubah, lakukan normalisasi sebelum hashing.
Status code yang konsisten
Status code harus mudah dipahami klien dan tetap konsisten lintas retry.
- Request pertama berhasil create:
201 Created. - Retry dengan key yang sama dan payload yang sama: kembalikan hasil yang sama. Praktiknya bisa tetap
201dengan body identik, atau200jika ingin menandai bahwa hasil berasal dari replay. Yang penting konsisten dan terdokumentasi. - Key sama tetapi payload berbeda:
409 Conflict. - Request sedang diproses oleh request pertama: bisa
409 Conflictatau429 Too Many Requestsdengan pesan bahwa operasi dengan key tersebut masih berjalan. Pilih satu pola dan dokumentasikan. - Header wajib tetapi tidak dikirim:
400 Bad Request.
Untuk kemudahan integrasi, banyak tim memilih mengembalikan body hasil pertama apa adanya pada replay, plus header tambahan agar klien tahu respons berasal dari cache idempotensi.
Idempotency-Replayed: trueAlur server: simpan hasil request pertama dan replay dengan aman
Tujuan implementasi bukan sekadar menyimpan bahwa suatu key pernah dipakai, tetapi menyimpan hasil final dari request pertama sehingga retry mendapatkan jawaban yang stabil.
Data minimal yang perlu disimpan
- Scope key lengkap.
- Fingerprint payload.
- Status pemrosesan:
in_progress,succeeded, ataufailedbila memang ingin menyimpan kegagalan tertentu. - Status code HTTP yang akan direplay.
- Body respons yang akan direplay.
- Metadata penting seperti waktu dibuat dan waktu kedaluwarsa.
Alur ideal
- Terima request dan validasi header
Idempotency-Key. - Buat scoped key dari user/tenant + route + method + idempotency key.
- Hitung fingerprint payload.
- Coba buat record idempotensi secara atomik dengan status
in_progress. - Jika insert berhasil, request ini adalah pemenang dan boleh menjalankan logika bisnis create.
- Jika insert gagal karena key sudah ada, baca record yang ada.
- Jika fingerprint berbeda, balas
409 Conflict. - Jika status
succeeded, replay respons yang tersimpan. - Jika status
in_progress, balas status konflik/try again sesuai kontrak. - Setelah logika bisnis berhasil, simpan status code dan body respons ke record idempotensi, lalu ubah status menjadi
succeeded.
Poin pentingnya: claim key harus atomik. Jika tidak, dua request paralel bisa sama-sama merasa menang lalu membuat dua resource.
Pseudocode backend
function createOrder(request, user) {
key = request.headers["Idempotency-Key"]
if (!key) {
return response(400, { error: "Idempotency-Key is required" })
}
scopedKey = buildScopedKey(user.id, "POST", "/v1/orders", key)
payloadHash = hashCanonicalJson(request.body)
created = idempotencyStore.tryInsert({
scoped_key: scopedKey,
payload_hash: payloadHash,
status: "in_progress",
created_at: now(),
expires_at: nowPlusTtl()
})
if (!created) {
existing = idempotencyStore.get(scopedKey)
if (existing.payload_hash != payloadHash) {
return response(409, {
error: "Idempotency-Key already used with different payload"
})
}
if (existing.status == "succeeded") {
return response(existing.http_status, existing.response_body, {
"Idempotency-Replayed": "true"
})
}
return response(409, {
error: "Request with the same Idempotency-Key is still processing"
})
}
try {
order = orderService.createOrder(user.id, request.body)
responseBody = {
order_id: order.id,
status: "created"
}
idempotencyStore.markSucceeded(scopedKey, 201, responseBody)
return response(201, responseBody)
} catch (err) {
idempotencyStore.delete(scopedKey)
throw err
}
}Pada contoh di atas, record in_progress dihapus jika proses gagal sebelum ada hasil final yang aman direplay. Ini cocok untuk kegagalan yang ingin diizinkan untuk dicoba ulang sebagai request baru dengan key yang sama. Namun keputusan ini harus sadar trade-off, seperti dijelaskan di bagian berikutnya.
Bagaimana menangani kegagalan dan status yang bisa direplay
Tidak semua kegagalan sebaiknya diperlakukan sama. Beberapa bisa aman direplay, sebagian lain tidak.
Simpan atau hapus hasil gagal?
- Validation error seperti field wajib kosong biasanya deterministik. Menyimpan dan mereplay
400bisa masuk akal. - Business conflict yang deterministik juga bisa disimpan jika memang mewakili hasil final untuk payload tersebut.
- Error sementara seperti gangguan database atau timeout ke layanan downstream biasanya tidak ideal disimpan sebagai hasil final jangka panjang.
Pola aman yang umum:
- Simpan hasil sukses.
- Simpan sebagian kegagalan deterministik yang memang final.
- Untuk kegagalan internal sementara, lepaskan claim atau tandai sebagai gagal sementara agar retry berikutnya masih bisa mencoba lagi.
Yang harus dihindari adalah menyimpan 500 mentah sebagai hasil final tanpa pertimbangan, karena klien bisa terjebak menerima error yang sama meski masalahnya sudah lewat.
Masalah timeout setelah side effect terjadi
Skenario paling berbahaya adalah proses bisnis sebenarnya sudah berhasil, tetapi server timeout sebelum sempat menyimpan hasil idempotensi atau mengirim respons. Jika ini terjadi, retry bisa membuat duplikasi bila sistem tidak punya pengaman tambahan.
Mitigasinya:
- Usahakan pembuatan resource bisnis dan penyimpanan hasil idempotensi berada dalam batas transaksi yang jelas jika memakai database yang sama.
- Jika melibatkan sistem eksternal, simpan referensi unik bisnis yang juga bisa dideduplikasi, misalnya
merchant_order_refataupayment_reference. - Jangan menggantungkan keamanan hanya pada cache volatile bila efek samping bisnis disimpan di tempat lain.
Mencegah race condition pada request paralel
Idempotensi gagal jika dua request identik yang datang hampir bersamaan sama-sama lolos. Race condition biasanya muncul ketika implementasi memakai pola:
if (!exists(key)) {
save(key)
process()
}Pola itu tidak aman karena dua proses bisa membaca exists = false sebelum salah satu sempat menyimpan data.
Pola yang benar
Gunakan operasi atomik:
- Database:
INSERTdengan unique constraint padascoped_key. - Redis: operasi set-if-not-exists dengan TTL.
Setelah claim berhasil, baru lanjutkan proses bisnis. Semua request lain harus membaca status yang sudah ada, bukan membuat claim baru.
Normalisasi payload untuk mencegah false conflict
Jika fingerprint dibuat dari body mentah, request yang semantik-nya sama bisa terlihat berbeda karena:
- Urutan field JSON berbeda.
- Spasi atau formatting berbeda.
- Nilai default yang kadang dikirim, kadang tidak.
Solusinya adalah membuat bentuk kanonik dari payload sebelum hashing. Jika ini sulit, setidaknya hash field-field bisnis yang benar-benar menentukan identitas operasi.
Trade-off Redis vs database untuk penyimpanan idempotensi
Pilihan storage menentukan jaminan konsistensi, kompleksitas operasional, dan risiko kehilangan data.
Redis: cepat dan sederhana untuk TTL
Kelebihan:
- Operasi atomik dan cepat untuk claim key.
- TTL bawaan memudahkan pembersihan otomatis.
- Cocok untuk beban tinggi dan replay respons jangka pendek.
Kekurangan:
- Jika Redis dipakai sebagai cache volatile, kehilangan data dapat membuat retry lama diperlakukan sebagai request baru.
- Kurang ideal jika Anda butuh audit kuat atas hasil create.
- Koordinasi dengan data bisnis di database utama bisa lebih rumit.
Database: lebih kuat untuk konsistensi bisnis
Kelebihan:
- Unique constraint dan transaksi membantu mencegah duplikasi secara lebih kuat.
- Lebih cocok jika hasil create dan metadata idempotensi ingin disimpan bersama.
- Audit dan investigasi insiden lebih mudah.
Kekurangan:
- Beban write/read tambahan ke database utama.
- TTL perlu dikelola sendiri dengan job pembersihan atau partisi data.
- Replay body respons besar bisa membuat tabel cepat tumbuh.
Pilih yang mana?
- Pilih database jika operasi create sangat kritikal dan konsistensi lebih penting daripada latensi minimum.
- Pilih Redis jika Anda butuh performa tinggi dengan jendela retry terbatas, dan sudah punya pengaman bisnis lain terhadap duplikasi.
- Pada sistem yang lebih ketat, kombinasi keduanya bisa dipakai: database sebagai sumber kebenaran, Redis sebagai akselerator replay. Namun ini menambah kompleksitas sinkronisasi.
Contoh kontrak API untuk pembuatan order atau payment
Request
POST /v1/orders
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: 8f6d7d2e-6c1a-4b8f-a0b9-0f2c9d1a7e31
{
"customer_id": "cust_123",
"items": [
{ "sku": "SKU-1", "qty": 2 }
],
"currency": "IDR"
}Respons sukses pertama
HTTP/1.1 201 Created
Content-Type: application/json
{
"order_id": "ord_987",
"status": "created"
}Respons retry dengan payload yang sama
HTTP/1.1 201 Created
Content-Type: application/json
Idempotency-Replayed: true
{
"order_id": "ord_987",
"status": "created"
}Respons key sama tetapi payload berbeda
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "Idempotency-Key already used with different payload"
}Respons saat request pertama masih berjalan
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "Request with the same Idempotency-Key is still processing"
}Anda bisa mengganti 409 untuk kasus in-progress dengan pola lain, tetapi jangan membuat klien menebak-nebak. Dokumentasikan apakah klien harus menunggu, melakukan polling, atau retry dengan backoff.
Checklist implementasi desain API idempotent untuk POST create
- Tentukan endpoint mana yang wajib memakai
Idempotency-Key. - Definisikan scope key: tenant/user + route + method + key.
- Gunakan claim atomik dengan unique constraint atau operasi set-if-not-exists.
- Simpan fingerprint payload yang sudah dinormalisasi.
- Tentukan kebijakan jelas untuk payload sama vs berbeda.
- Simpan hasil sukses pertama agar replay stabil.
- Tentukan apakah kegagalan deterministik juga disimpan.
- Definisikan status code yang konsisten untuk create, replay, conflict, dan in-progress.
- Tetapkan TTL sesuai jendela retry nyata.
- Tambahkan observability: log key, scope, status, dan apakah respons replay.
- Lindungi storage dari pertumbuhan tak terkendali dengan cleanup yang jelas.
- Uji skenario paralel, timeout, retry beruntun, dan restart proses.
Kesalahan umum saat integrasi client-server
Masalah idempotensi sering bukan di algoritma inti, tetapi di detail integrasi antara klien dan server.
1. Klien membuat key baru saat retry
Jika timeout terjadi lalu klien menghasilkan Idempotency-Key baru, server tidak punya cara mengetahui bahwa itu operasi yang sama. Retry harus memakai key yang sama sampai klien yakin operasi gagal atau jendela retry berakhir.
2. Satu key dipakai untuk aksi berbeda
Ini memicu conflict yang benar, tetapi sering membingungkan tim integrasi. Dokumentasikan bahwa satu key hanya untuk satu percobaan create yang identik, bukan untuk satu sesi pengguna atau satu halaman form.
3. Server hanya menyimpan key, bukan hasil respons
Akibatnya retry mungkin hanya mendapat pesan generik seperti “sudah pernah diproses”, padahal klien membutuhkan order_id atau payment_id hasil create pertama. Simpan respons final yang relevan.
4. Tidak ada pembeda payload sama dan berbeda
Jika server selalu menganggap key yang sama sebagai replay, permintaan yang sebenarnya berbeda bisa diam-diam mendapatkan hasil lama. Ini berbahaya, terutama pada nominal pembayaran atau isi order.
5. Retry otomatis terlalu agresif
Klien yang melakukan retry paralel tanpa backoff dapat membuat banyak request in-progress sekaligus. Retry sebaiknya serial, memakai key yang sama, dan memiliki jeda yang masuk akal.
6. TTL terlalu pendek
Klien mobile atau jaringan antar region bisa mengalami timeout yang lebih lama dari perkiraan. Jika key sudah kedaluwarsa saat retry, duplikasi bisa muncul walau desainnya tampak benar.
7. Tidak mengaitkan idempotensi dengan identitas pemanggil
Key yang sama dari dua akun berbeda tidak boleh saling membaca atau menabrak hasil. Scope per user atau tenant bukan detail kecil, tetapi bagian dari keamanan dan isolasi data.
Penutup
Desain API idempotent untuk POST create yang benar bukan sekadar menerima header tambahan. Anda perlu kontrak yang tegas: kapan Idempotency-Key wajib, bagaimana scope key dibentuk, berapa lama disimpan, bagaimana membedakan payload yang sama dan berbeda, serta status code apa yang dikembalikan pada replay atau konflik.
Jika endpoint create Anda berhubungan dengan order atau payment, anggap retry sebagai kondisi normal, bukan edge case. Simpan hasil request pertama, lakukan claim key secara atomik, dan uji skenario paralel serta timeout secara nyata. Dengan begitu, retry jaringan dan double submit tidak lagi berubah menjadi duplikasi data bisnis.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!