Pada aplikasi yang menerima payment callback, webhook pihak ketiga, atau form checkout dari UI, request ganda adalah kondisi normal, bukan pengecualian. Retry bisa datang dari browser, user yang klik tombol dua kali, reverse proxy, timeout jaringan, worker queue, atau provider webhook yang memang mengirim ulang event sampai mendapat respons yang dianggap sukses.
Karena itu, kontrak API di Nuxt.js tidak cukup hanya valid secara skema. Kontrak tersebut harus tahan retry: request yang sama tidak boleh memproses efek samping dua kali, hasil respons harus konsisten, dan sistem harus tetap aman saat terjadi timeout, race condition, atau partial failure. Di Nuxt 3, ini biasanya diimplementasikan pada Nitro server routes dengan kombinasi idempotency key, request fingerprint, penyimpanan hasil request, dan deduplikasi di database atau Redis.
Kapan duplicate request terjadi
Sebelum membahas implementasi, penting untuk memahami sumber duplikasi. Ini menentukan desain kontrak API yang tepat.
1. Double submit dari UI
Kasus paling umum: user menekan tombol submit dua kali, browser melakukan retry setelah koneksi putus, atau frontend mengirim ulang request karena mengira request sebelumnya gagal. Disable tombol submit membantu, tetapi tidak cukup. Perlindungan utama tetap harus ada di server.
2. Retry otomatis dari client atau proxy
Library HTTP, mobile SDK, API gateway, dan reverse proxy kadang melakukan retry untuk request yang gagal secara jaringan. Masalahnya, server mungkin sebenarnya sudah memproses request pertama, tetapi client tidak menerima respons karena timeout.
3. Webhook redelivery dari provider
Banyak provider webhook akan mengirim ulang event yang sama sampai endpoint mengembalikan status yang dianggap berhasil. Beberapa provider juga mengirim event out-of-order atau mengirim event yang sama beberapa kali dalam interval panjang. Jangan asumsikan satu event hanya datang sekali.
4. Race condition antar worker atau instance
Pada deployment horizontal, dua request identik bisa masuk hampir bersamaan ke instance Nitro yang berbeda. Jika deduplikasi hanya disimpan di memori proses, perlindungan akan gagal.
Prinsip kontrak API tahan retry di Nuxt.js
Untuk endpoint POST yang membuat efek samping seperti membuat order, mencatat pembayaran, atau mengubah status resource, kontrak API sebaiknya memiliki beberapa komponen berikut.
Idempotency key
Idempotency key adalah identifier unik dari client untuk satu niat operasi. Contohnya, ketika user menekan tombol bayar, frontend membuat satu key acak dan mengirimkannya lewat header. Jika request yang sama dikirim ulang dengan key yang sama, server harus mengembalikan hasil yang sama atau status yang konsisten, bukan memproses ulang efek samping.
Header yang umum dipakai:
Idempotency-Key: 7f3a0f19-6d15-4c34-9e43-1e2a6b3f9d10Catatan penting: idempotency key tidak boleh dipakai ulang untuk operasi yang berbeda. Satu key mewakili satu intent bisnis.
Request fingerprint
Idempotency key saja belum cukup. Jika client secara tidak sengaja memakai key yang sama untuk payload berbeda, server harus bisa mendeteksi konflik. Di sinilah request fingerprint dipakai, biasanya berupa hash dari kombinasi berikut:
- HTTP method
- path atau route
- identitas tenant/user
- field payload yang relevan
Jika key sama tetapi fingerprint berbeda, respons yang aman biasanya adalah 409 Conflict atau 422 Unprocessable Entity, tergantung kontrak yang dipilih. Yang penting: konsisten dan terdokumentasi.
Penyimpanan hasil request
Jika request pertama sukses membuat resource, request kedua dengan key yang sama sebaiknya tidak hanya dikembalikan sebagai "sudah pernah diproses". Lebih baik server menyimpan hasil penting dari request pertama, misalnya:
- status pemrosesan: processing, succeeded, failed
- status code HTTP
- identifier resource yang dibuat
- ringkasan body respons
- fingerprint request
- waktu kedaluwarsa key
Dengan begitu, retry berikutnya bisa menerima hasil yang konsisten tanpa mengulang side effect.
Deduplikasi pada storage bersama
Untuk Nuxt 3 yang berjalan di beberapa instance, deduplikasi harus disimpan di storage bersama seperti database atau Redis. Menyimpan state di variabel global server tidak aman untuk deployment horizontal maupun restart proses.
Skema kontrak endpoint POST yang disarankan
Berikut contoh kontrak untuk endpoint pembuatan order di Nitro route:
POST /api/orders
Content-Type: application/json
Idempotency-Key: <uuid-atau-random-key>
X-Request-Timestamp: <optional>
{
"cartId": "crt_123",
"paymentMethod": "bank_transfer",
"amount": 150000
}Aturan perilaku endpoint
- Validasi payload dan autentikasi request.
- Ambil Idempotency-Key. Jika endpoint ini wajib idempotent, tolak request tanpa key dengan 400.
- Buat fingerprint dari method + route + actor + payload penting.
- Coba buat record idempotency secara atomik di database/Redis.
- Jika record baru berhasil dibuat, tandai status processing lalu lanjutkan proses bisnis.
- Jika key sudah ada:
- jika fingerprint berbeda, balas 409 Conflict
- jika status masih processing, balas 409 atau 202 sesuai kontrak
- jika status succeeded, kembalikan hasil tersimpan
- jika status failed, tentukan apakah boleh retry dengan key yang sama atau harus key baru
- Setelah proses selesai, simpan hasil final beserta status code dan body respons ringkas.
Status code yang konsisten
Tidak ada satu-satunya pilihan yang benar, tetapi kontrak harus tegas. Contoh yang praktis:
- 201 Created: request pertama sukses membuat resource baru
- 200 OK: retry dengan key yang sama mengembalikan resource yang sudah ada
- 202 Accepted: request diterima tetapi proses masih berjalan secara async
- 409 Conflict: key sama dipakai untuk payload berbeda, atau request identik sedang diproses dan kontrak Anda memilih konflik
- 400 Bad Request: header wajib seperti Idempotency-Key tidak ada
Jika memungkinkan, sertakan field yang membantu client memahami hasilnya, misalnya:
{
"id": "ord_789",
"status": "created",
"idempotency": {
"key": "7f3a0f19-6d15-4c34-9e43-1e2a6b3f9d10",
"replayed": false
}
}Untuk retry yang mengambil hasil lama:
{
"id": "ord_789",
"status": "created",
"idempotency": {
"key": "7f3a0f19-6d15-4c34-9e43-1e2a6b3f9d10",
"replayed": true
}
}Contoh implementasi handler Nuxt 3 Nitro
Berikut contoh pseudo-code yang cukup dekat dengan handler Nitro. Contoh ini sengaja generik agar tidak bergantung pada ORM tertentu.
// server/api/orders.post.ts
import { createHash } from 'node:crypto'
function stableStringify(input: unknown) {
return JSON.stringify(input, Object.keys(input as Record<string, unknown>).sort())
}
function buildFingerprint(method: string, path: string, actorId: string, payload: any) {
const relevantPayload = {
cartId: payload.cartId,
paymentMethod: payload.paymentMethod,
amount: payload.amount
}
return createHash('sha256')
.update(`${method}:${path}:${actorId}:${stableStringify(relevantPayload)}`)
.digest('hex')
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const actorId = event.context.user?.id || 'anonymous'
const key = getHeader(event, 'idempotency-key')
if (!key) {
throw createError({ statusCode: 400, statusMessage: 'Idempotency-Key is required' })
}
const fingerprint = buildFingerprint('POST', '/api/orders', actorId, body)
// Pseudo: simpan secara atomik di shared storage
// insert jika belum ada, unik berdasarkan (actorId, key)
const existing = await idempotencyStore.find(actorId, key)
if (existing) {
if (existing.fingerprint !== fingerprint) {
throw createError({ statusCode: 409, statusMessage: 'Idempotency key reused with different payload' })
}
if (existing.status === 'succeeded') {
setResponseStatus(event, existing.responseStatusCode || 200)
return {
...existing.responseBody,
idempotency: { key, replayed: true }
}
}
if (existing.status === 'processing') {
throw createError({ statusCode: 409, statusMessage: 'Request is already being processed' })
}
// Jika status failed, Anda bisa pilih retryable atau tidak.
}
const locked = await idempotencyStore.createIfAbsent({
actorId,
key,
fingerprint,
status: 'processing',
createdAt: new Date().toISOString()
})
if (!locked) {
// race condition: instance lain menang lebih dulu
const latest = await idempotencyStore.find(actorId, key)
if (latest?.fingerprint !== fingerprint) {
throw createError({ statusCode: 409, statusMessage: 'Idempotency conflict' })
}
throw createError({ statusCode: 409, statusMessage: 'Request is already being processed' })
}
try {
// Pseudo proses bisnis
const order = await orderService.createOrder({
actorId,
cartId: body.cartId,
paymentMethod: body.paymentMethod,
amount: body.amount
})
const responseBody = {
id: order.id,
status: 'created'
}
await idempotencyStore.markSucceeded(actorId, key, {
responseStatusCode: 201,
responseBody
})
setResponseStatus(event, 201)
return {
...responseBody,
idempotency: { key, replayed: false }
}
} catch (error) {
await idempotencyStore.markFailed(actorId, key, {
errorType: 'order_creation_failed'
})
throw error
}
})Kenapa pola ini bekerja
- Key mengikat retry ke satu intent operasi.
- Fingerprint mencegah key yang sama dipakai untuk request berbeda.
- Create-if-absent atomik mencegah dua instance memproses request identik secara bersamaan.
- Penyimpanan response memungkinkan replay hasil yang konsisten saat retry datang setelah timeout.
Gunakan mekanisme atomik di storage. Di database biasanya lewat unique constraint + insert transaction. Di Redis bisa memakai operasi seperti set-if-not-exists dengan TTL, tetapi tetap pikirkan bagaimana menyimpan hasil final dan bukan hanya lock sementara.
Pilih database atau Redis untuk deduplikasi?
Database
Cocok jika Anda butuh sumber kebenaran yang kuat, integrasi dekat dengan data bisnis, dan constraint unik yang jelas.
Kelebihan:
- Atomicity lebih natural jika dikombinasikan dengan pembuatan resource bisnis
- Mudah diaudit
- Cocok untuk retention lebih lama
Kekurangan:
- Beban write tambahan
- Perlu desain tabel yang rapi agar tidak membengkak
Redis
Cocok jika Anda butuh deduplikasi cepat, TTL alami, dan volume request tinggi.
Kelebihan:
- Latency rendah
- TTL mudah untuk key sementara
- Baik untuk status processing jangka pendek
Kekurangan:
- Jika hanya menyimpan lock tanpa hasil final, retry setelah timeout masih sulit dijawab konsisten
- Perlu hati-hati pada durability dan recovery
Pola gabungan
Pola yang sering paling praktis adalah:
- Redis untuk lock cepat dan status processing jangka pendek
- Database untuk hasil final, audit, dan unique constraint bisnis
Jika sistem Anda belum kompleks, mulai dari database saja sering lebih sederhana dan lebih aman.
Webhook provider: kesalahan umum dan pola yang benar
Gunakan event ID provider sebagai kunci deduplikasi
Pada webhook, sering kali provider sudah mengirim event ID unik. Jangan membuat idempotency key sendiri jika event ID itu sudah tersedia dan stabil. Simpan event ID sebagai dedupe key, lalu pastikan event yang sama tidak diproses dua kali.
Verifikasi signature sebelum memproses
Jangan lakukan deduplikasi atau update state bisnis sebelum validasi autentikasi webhook. Jika signature tidak valid, tolak request lebih awal.
Jangan mengandalkan urutan event
Provider bisa mengirim event payment_succeeded sebelum event lain yang Anda harapkan datang, atau mengirim event lama setelah event baru. Desain handler webhook agar berdasarkan state aktual dan transisi yang valid, bukan asumsi urutan.
Balas cepat, proses berat di background jika perlu
Jika verifikasi dan pencatatan event sudah aman, sering kali lebih baik mengembalikan respons sukses secepat mungkin lalu memproses pekerjaan berat secara async. Ini mengurangi redelivery karena timeout.
Kesalahan umum integrasi payment/provider webhook
- Menganggap status 2xx pasti berarti event belum pernah diproses sebelumnya
- Tidak menyimpan event ID atau payload ringkas untuk audit
- Memproses side effect sebelum signature diverifikasi
- Tidak menangani event yang sama datang berulang hari berikutnya
- Mengupdate status bisnis tanpa memeriksa transisi state yang valid
- Mengirim 500 untuk error bisnis yang sebenarnya tidak perlu redelivery
Untuk webhook, kadang keputusan respons perlu dibedakan antara:
- Error sementara: misalnya dependency internal down, sehingga redelivery berguna
- Error permanen: payload invalid atau event tidak relevan, sehingga mengembalikan non-retryable response lebih tepat sesuai kontrak provider
Pastikan Anda membaca dokumentasi provider tentang perilaku retry mereka, tetapi jangan hardcode asumsi yang tidak perlu ke dalam logika inti.
Edge case yang sering merusak implementasi
1. Timeout setelah proses sebenarnya sukses
Ini kasus klasik. Server berhasil membuat order, tetapi respons ke client timeout. Client lalu retry. Jika hasil request pertama tidak disimpan, server bisa membuat order kedua. Solusinya: simpan hasil final yang bisa direplay.
2. Race condition pada request bersamaan
Dua request identik bisa masuk dalam milidetik yang sama. Jika alur Anda adalah "cek dulu, lalu insert", tanpa operasi atomik keduanya bisa lolos. Solusinya: pakai unique constraint, transaction, atau primitive atomik Redis.
3. Partial failure
Contoh: order sudah masuk database, tetapi publish event ke broker gagal. Jika Anda langsung menandai request gagal total, retry mungkin mencoba membuat order lagi. Di sini Anda perlu membedakan:
- apakah resource bisnis inti sudah terbentuk?
- operasi lanjutan mana yang bisa dipulihkan async?
Praktik yang aman adalah menyimpan status bisnis utama secara transaksional, lalu menggunakan pola outbox atau job retry terpisah untuk efek samping lanjutan.
4. Reuse key lintas user atau tenant
Jangan menjadikan idempotency key global tanpa namespace. Key yang sama dari dua tenant berbeda tidak boleh saling bentrok. Minimal, scope dedupe harus memasukkan actor, account, atau tenant.
5. TTL terlalu pendek
Jika key dedupe dihapus terlalu cepat, retry yang datang terlambat akan diproses sebagai request baru. Tentukan TTL berdasarkan karakter sistem: UI submit biasanya lebih pendek, webhook bisa perlu lebih lama.
Desain tabel atau record idempotency
Skema minimal yang umum dipakai:
- scope: user_id, tenant_id, atau provider name
- idempotency_key
- fingerprint
- status: processing, succeeded, failed
- response_status_code
- response_body atau referensi ke resource ID
- error_summary
- created_at, updated_at, expires_at
Tambahkan unique constraint yang sesuai, misalnya kombinasi (scope, idempotency_key). Untuk webhook, kombinasi seperti (provider, event_id) biasanya lebih tepat.
Debugging dan observability
Kontrak idempotent sulit dioperasikan tanpa observability yang baik. Minimal, log dan metrik berikut sebaiknya ada:
- idempotency key
- fingerprint ringkas atau hash
- status dedupe: miss, hit-succeeded, hit-processing, conflict
- resource ID hasil proses
- durasi handler
- alasan gagal: timeout, validation error, downstream error
Ini membantu menjawab pertanyaan seperti:
- Apakah order dobel dibuat karena key tidak dikirim?
- Apakah fingerprint berubah karena serialisasi payload tidak stabil?
- Apakah Redis lock hilang sebelum hasil final tersimpan?
Checklist implementasi produksi
- Tentukan endpoint POST mana yang wajib idempotent.
- Wajibkan Idempotency-Key untuk operasi yang membuat efek samping penting.
- Scope key berdasarkan user, tenant, atau konteks provider webhook.
- Buat fingerprint dari payload yang relevan, bukan seluruh body mentah yang mudah berubah formatnya.
- Gunakan storage bersama: database, Redis, atau kombinasi keduanya.
- Pastikan operasi dedupe bersifat atomik.
- Simpan hasil final request agar retry bisa mendapat respons konsisten.
- Definisikan status code untuk succeeded replay, processing, dan conflict.
- Untuk webhook, verifikasi signature sebelum memproses.
- Jangan mengasumsikan urutan event webhook.
- Tentukan TTL dan kebijakan retention yang realistis.
- Tambahkan log, trace, dan metrik khusus idempotency.
- Uji skenario timeout, retry bersamaan, dan partial failure.
Penutup
Di Nuxt.js, membuat endpoint POST yang aman terhadap retry bukan sekadar menambahkan header lalu selesai. Anda perlu kontrak yang lengkap: idempotency key untuk mengikat intent, request fingerprint untuk mencegah konflik, penyimpanan hasil request untuk replay yang konsisten, dan deduplikasi atomik di database atau Redis untuk menghadapi race condition.
Jika Anda membangun checkout, webhook payment, atau endpoint bisnis penting di Nitro server/API routes, anggap duplicate request sebagai kondisi normal. Dengan desain ini, retry dari UI, timeout jaringan, dan webhook redelivery tidak lagi berujung pada order ganda atau state yang sulit dipulihkan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!