Idempotency key adalah pola untuk memastikan request POST yang sama tidak dieksekusi lebih dari sekali meskipun dikirim ulang karena timeout, retry otomatis, atau webhook yang terlambat menerima respons. Dalam konteks pembayaran, checkout, atau enqueue job, pola ini penting untuk mencegah double charge, double order, dan duplikasi proses.
Di SvelteKit, idempotency biasanya diterapkan di endpoint +server.ts dengan cara menerima sebuah key unik dari client, menyimpannya bersama jejak request dan hasil respons, lalu mengembalikan respons yang sama jika request identik datang lagi. Kuncinya bukan sekadar menyimpan key, tetapi juga menangani race condition, payload berbeda dengan key yang sama, TTL, dan konsistensi status code.
Kapan idempotency key diperlukan
Tidak semua POST butuh idempotency key. Pola ini paling berguna ketika operasi memiliki efek samping yang mahal, irreversible, atau tidak boleh terduplikasi.
- Pembayaran: membuat charge ke payment gateway.
- Pembuatan order: mencegah satu checkout menghasilkan dua pesanan.
- Webhook consumer: provider sering melakukan retry jika respons lambat atau gagal.
- Job dispatch: enqueue task yang tidak boleh diproses dua kali.
- API dengan client mobile: jaringan tidak stabil sering memicu retry.
Sebaliknya, idempotency key sering tidak perlu untuk operasi yang sudah alami idempoten, misalnya PUT yang mengganti resource ke state tertentu, atau operasi baca seperti GET. Ia juga mungkin berlebihan untuk endpoint internal yang sudah dilindungi mekanisme deduplikasi di layer lain.
Kontrak API: header, payload, dan perilaku respons
Header yang umum dipakai
Gunakan header seperti Idempotency-Key. Client harus mengirim nilai unik untuk satu aksi bisnis, misalnya satu percobaan checkout.
POST /api/orders
Idempotency-Key: 6f4d5b4b-0d24-4d5d-9fe8-6b69ef2dc8f8
Content-Type: application/jsonPraktik yang aman:
- Key dibangkitkan oleh client, biasanya UUID atau string acak dengan entropi tinggi.
- Satu key dipakai untuk satu operasi logis, bukan untuk semua request user.
- Key sebaiknya dibatasi per tenant/user agar tidak terjadi benturan lintas akun.
Payload harus konsisten
Jika request retry memakai key yang sama, payload seharusnya identik. Karena itu, server perlu menyimpan fingerprint dari request, misalnya hash dari method, path, tenant, dan body yang sudah dinormalisasi. Jika ada request baru dengan key sama tetapi payload berbeda, respons harus ditolak.
Catatan: Menyamakan key tanpa memeriksa payload adalah kesalahan umum. Ini bisa membuat request yang sebenarnya berbeda dianggap retry, lalu mengembalikan hasil yang salah.
Status code yang konsisten
Untuk request retry yang identik, kembalikan status code dan body yang sama seperti request pertama. Ini penting agar client melihat perilaku deterministik.
Contoh aturan praktis:
- Request pertama sukses → simpan respons, misalnya
201dengan body order. - Retry dengan key dan payload sama → kembalikan lagi
201dengan body yang sama. - Key sama, payload berbeda → tolak dengan
409 Conflictatau422 Unprocessable Entity. Pilih satu dan konsisten. - Request paralel saat request pertama masih diproses → bisa kembalikan
409 Conflict,425 Too Early, atau202 Acceptedtergantung kontrak. Yang penting jelas dan konsisten.
Desain penyimpanan idempotency key
Apa yang perlu disimpan
Minimal, record idempotency menyimpan:
- idempotency_key
- scope atau tenant/user id
- request_hash
- status: misalnya
processingataucompleted - response_status
- response_body
- created_at dan expires_at
Jika respons cukup besar, Anda bisa menyimpan referensi ke resource yang dibuat, misalnya order_id, lalu membangun ulang respons dari sana. Namun untuk retry yang benar-benar identik, menyimpan body respons sering lebih sederhana.
Skema tabel sederhana
CREATE TABLE api_idempotency (
id BIGSERIAL PRIMARY KEY,
scope VARCHAR(100) NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
request_hash VARCHAR(128) NOT NULL,
status VARCHAR(20) NOT NULL,
response_status INTEGER,
response_body TEXT,
resource_type VARCHAR(50),
resource_id VARCHAR(100),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
UNIQUE (scope, idempotency_key)
);scope penting agar key yang sama dari user berbeda tidak saling bertabrakan. Untuk sistem multi-tenant, scope bisa berupa tenant_id:user_id:endpoint atau bentuk lain yang masuk akal untuk domain Anda.
TTL dan retensi data
Idempotency record tidak perlu disimpan selamanya. Tentukan TTL berdasarkan pola retry di sistem Anda.
- Beberapa jam cocok untuk client app biasa.
- 1-3 hari sering masuk akal untuk webhook atau payment callback.
- Lebih lama jika ada gateway yang bisa mengirim ulang cukup lambat.
TTL terlalu pendek berisiko membuat retry yang sah dianggap request baru. TTL terlalu panjang meningkatkan penyimpanan dan peluang benturan key lama. Pilih sesuai karakter retry bisnis, bukan angka acak.
Alur request pertama vs retry
Request pertama
- Server membaca
Idempotency-Key. - Server memvalidasi payload dan menghitung
request_hash. - Server mencoba membuat record baru dengan status
processing. - Jika berhasil, server menjalankan operasi bisnis, misalnya membuat order atau charge.
- Setelah sukses, server menyimpan status respons dan body ke record idempotency, lalu mengembalikannya ke client.
Retry dengan payload sama
- Server mencari record berdasarkan
scope + key. - Jika status
completeddanrequest_hashsama, server mengembalikan respons yang tersimpan. - Client menerima hasil yang sama tanpa efek samping kedua kali.
Retry saat request pertama masih diproses
- Request kedua menemukan record dengan status
processing. - Server tidak menjalankan operasi bisnis lagi.
- Server mengembalikan status yang menjelaskan bahwa request sedang diproses, atau meminta client retry lagi setelah jeda.
Contoh implementasi endpoint POST di SvelteKit
Contoh berikut menunjukkan pola inti di file src/routes/api/orders/+server.ts. Kode ini sengaja dibuat generik agar fokus pada desain idempotency, bukan pada ORM tertentu.
import { json } from '@sveltejs/kit';
import crypto from 'node:crypto';
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return '[' + value.map(stableStringify).join(',') + ']';
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
}
function sha256(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex');
}
// Ganti dengan akses database Anda sendiri
const db = {
async findIdempotency(scope: string, key: string) {
// SELECT * FROM api_idempotency WHERE scope = ? AND idempotency_key = ?
return null as null | {
scope: string;
idempotency_key: string;
request_hash: string;
status: 'processing' | 'completed';
response_status: number | null;
response_body: string | null;
expires_at: Date;
};
},
async insertProcessing(record: {
scope: string;
idempotency_key: string;
request_hash: string;
expires_at: Date;
}) {
// INSERT ... status='processing'
// Harus gagal jika unique(scope, idempotency_key) bentrok
},
async markCompleted(scope: string, key: string, responseStatus: number, responseBody: string) {
// UPDATE ... SET status='completed', response_status=?, response_body=?
},
async markFailedOrDelete(scope: string, key: string) {
// Tergantung strategi Anda: hapus record atau tandai gagal
},
async createOrder(input: { userId: string; itemId: string; quantity: number }) {
// Operasi bisnis yang harus hanya dieksekusi sekali
return {
id: 'ord_123',
itemId: input.itemId,
quantity: input.quantity,
status: 'created'
};
}
};
export async function POST({ request, locals }) {
const userId = locals.user?.id;
if (!userId) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const idempotencyKey = request.headers.get('idempotency-key');
if (!idempotencyKey) {
return json({ error: 'Missing Idempotency-Key header' }, { status: 400 });
}
let payload: { itemId?: string; quantity?: number };
try {
payload = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!payload.itemId || !payload.quantity || payload.quantity <= 0) {
return json({ error: 'Invalid payload' }, { status: 422 });
}
const scope = `user:${userId}:POST:/api/orders`;
const normalizedBody = stableStringify(payload);
const requestHash = sha256(`${scope}|${normalizedBody}`);
const existing = await db.findIdempotency(scope, idempotencyKey);
if (existing) {
if (existing.request_hash !== requestHash) {
return json(
{ error: 'Idempotency key already used with different payload' },
{ status: 409 }
);
}
if (existing.status === 'completed' && existing.response_body && existing.response_status) {
return new Response(existing.response_body, {
status: existing.response_status,
headers: {
'content-type': 'application/json',
'x-idempotency-replayed': 'true'
}
});
}
return json(
{ error: 'Request with the same idempotency key is still processing' },
{
status: 409,
headers: { 'retry-after': '2' }
}
);
}
const ttlHours = 24;
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000);
try {
await db.insertProcessing({
scope,
idempotency_key: idempotencyKey,
request_hash: requestHash,
expires_at: expiresAt
});
} catch {
// Kemungkinan race condition: request paralel mencoba insert key yang sama.
const raced = await db.findIdempotency(scope, idempotencyKey);
if (raced && raced.request_hash === requestHash && raced.status === 'completed' && raced.response_body && raced.response_status) {
return new Response(raced.response_body, {
status: raced.response_status,
headers: {
'content-type': 'application/json',
'x-idempotency-replayed': 'true'
}
});
}
return json(
{ error: 'Duplicate request is being processed' },
{ status: 409, headers: { 'retry-after': '2' } }
);
}
try {
const order = await db.createOrder({
userId,
itemId: payload.itemId,
quantity: payload.quantity
});
const responseBody = JSON.stringify({ order });
const responseStatus = 201;
await db.markCompleted(scope, idempotencyKey, responseStatus, responseBody);
return new Response(responseBody, {
status: responseStatus,
headers: { 'content-type': 'application/json' }
});
} catch (error) {
await db.markFailedOrDelete(scope, idempotencyKey);
return json({ error: 'Failed to create order' }, { status: 500 });
}
}Mengapa pendekatan ini bekerja
- Unique constraint pada
(scope, idempotency_key)mencegah dua request pertama-sama lolos sebagai request baru. - request_hash memastikan retry yang sah identik dengan request awal.
- status=processing mencegah request paralel mengeksekusi operasi bisnis dua kali.
- response_status + response_body memungkinkan server mengembalikan hasil yang sama secara konsisten.
Menangani race condition dengan benar
Race condition adalah masalah inti dalam idempotency. Dua request dengan key yang sama bisa tiba hampir bersamaan, terutama saat client melakukan retry agresif atau load balancer mengulang request.
Kesalahan yang sering terjadi
Pola check then insert tanpa perlindungan database tidak aman:
- Request A cek key, belum ada.
- Request B cek key, belum ada.
- A dan B sama-sama menjalankan charge/order.
Solusi yang lebih aman adalah mengandalkan unique constraint di database, lalu menangani konflik insert sebagai sinyal bahwa request lain sudah lebih dulu memegang key tersebut.
Status processing vs locking eksplisit
Ada dua pendekatan umum:
- Unique insert + status processing: sederhana dan cukup untuk banyak kasus.
- Transaksi + row lock: berguna jika alur bisnis lebih kompleks dan Anda perlu konsistensi lebih ketat.
Jika Anda memakai PostgreSQL atau database relasional lain, unique constraint hampir selalu menjadi fondasi utama. Lock tambahan bisa dipakai bila proses setelah insert melibatkan beberapa langkah yang perlu dijaga atomis.
Edge case yang perlu dipikirkan
1. Payload berbeda dengan key yang sama
Ini harus ditolak. Jangan pernah mengembalikan respons lama untuk payload baru hanya karena key-nya sama. Simpan hash request dan bandingkan setiap kali ada retry.
2. Request paralel
Jika request kedua datang saat yang pertama masih processing, jangan eksekusi ulang. Kembalikan error yang jelas dan sertakan Retry-After jika perlu. Alternatif lain adalah polling endpoint status, tetapi itu menambah kompleksitas.
3. Operasi bisnis sukses, tetapi penyimpanan respons gagal
Ini skenario yang sering diabaikan. Misalnya order berhasil dibuat, tetapi update record idempotency ke completed gagal. Solusi terbaik adalah sebisa mungkin menempatkan pembuatan resource dan update record dalam transaksi yang sesuai. Jika tidak bisa, simpan resource_id agar retry masih bisa direkonsiliasi.
4. Apakah error 500 ikut disimpan?
Ini keputusan desain. Untuk error sementara seperti gangguan database sesaat, sering lebih baik tidak menyimpan respons gagal permanen, agar retry dapat mencoba lagi. Namun untuk kegagalan setelah operasi bisnis mungkin sudah berjalan sebagian, Anda perlu desain kompensasi atau rekonsiliasi yang lebih hati-hati.
5. TTL habis lalu request lama di-retry
Setelah key kedaluwarsa, server bisa menganggap request sebagai request baru. Karena itu, TTL harus cukup panjang untuk menutup jendela retry realistis dari client atau provider webhook.
Cleanup data dan operasional
Pembersihan record kadaluwarsa
Buat job terjadwal untuk menghapus record dengan expires_at yang sudah lewat. Jika volume tinggi, hapus secara bertahap agar tidak membuat query besar yang mengganggu performa.
DELETE FROM api_idempotency
WHERE expires_at < NOW()
LIMIT 1000;Jika database Anda tidak mendukung LIMIT di bentuk query tertentu, lakukan batch lewat scheduler aplikasi atau gunakan strategi partisi/retensi yang sesuai dengan database yang dipakai.
Observability
Tambahkan log dan metrik untuk hal berikut:
- jumlah request baru vs replay,
- jumlah conflict karena payload berbeda,
- jumlah request yang tertahan di status
processing, - durasi operasi bisnis sebelum status menjadi
completed.
Ini membantu mendeteksi retry abnormal dari gateway, bug client, atau bottleneck di backend.
Kesalahan implementasi yang umum
- Menggunakan key global tanpa scope user/tenant, sehingga bentrok lintas akun.
- Tidak menyimpan request hash, sehingga payload berbeda lolos.
- Hanya menyimpan key, tanpa status processing, sehingga request paralel masih bisa dobel.
- Mengubah bentuk respons saat replay, padahal seharusnya konsisten dengan request pertama.
- TTL terlalu pendek, membuat retry yang valid menjadi request baru.
- Menganggap idempotency menggantikan semua transaksi bisnis. Padahal ia hanya salah satu lapisan proteksi.
Checklist implementasi
- Wajibkan header
Idempotency-Keyuntuk endpoint POST yang sensitif. - Tentukan scope yang jelas, misalnya per user/tenant dan endpoint.
- Hitung request hash dari payload yang dinormalisasi.
- Buat unique constraint pada
(scope, idempotency_key). - Simpan status
processingsebelum menjalankan operasi bisnis. - Simpan response status dan response body saat operasi selesai.
- Kembalikan respons yang sama untuk retry identik.
- Tolak key sama dengan payload berbeda.
- Tentukan TTL dan job cleanup yang realistis.
- Tambahkan log, metrik, dan alert untuk conflict dan retry berlebih.
Kapan pola ini tidak perlu dipakai
Idempotency key tidak selalu wajib. Anda bisa melewatkannya jika:
- endpoint tidak memiliki efek samping penting,
- operasi sudah idempoten secara alami,
- duplikasi sudah ditangani kuat di layer domain dengan unique business key,
- biaya penyimpanan dan kompleksitas operasional lebih besar daripada risikonya.
Namun untuk endpoint POST yang membuat charge, order, atau job penting, idempotency key di SvelteKit adalah proteksi yang praktis dan layak diterapkan. Implementasi yang baik bukan hanya menerima header unik, tetapi juga memastikan replay aman, status code konsisten, dan race condition tidak mengakibatkan efek samping ganda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!