Pada endpoint yang membuat efek samping seperti create order, charge payment, atau submit checkout, masalah terbesar bukan hanya validasi input, tetapi juga double submit akibat retry dari klien, timeout jaringan, tombol diklik dua kali, atau dua request identik yang tiba hampir bersamaan. Solusi yang lazim dan aman adalah menerapkan idempotency store: server menerima Idempotency-Key, menyimpan status request pertama, lalu mengembalikan respons yang sama untuk retry berikutnya selama masih dalam scope dan TTL yang valid.
Di Laravel, implementasi ini biasanya diletakkan pada middleware sebelum logika bisnis dijalankan. Kunci utamanya bukan sekadar “menolak request duplikat”, tetapi mendefinisikan kontrak API yang jelas: kapan klien wajib mengirim key, bagaimana scope key ditentukan, bagaimana payload diverifikasi dengan fingerprint, kapan server harus me-replay respons pertama, dan kapan harus mengembalikan 409 Conflict atau 422 Unprocessable Entity.
Masalah yang Sebenarnya Ingin Diselesaikan
Tanpa idempotency, skenario berikut sangat mudah terjadi:
- Klien mengirim
POST /orders, server berhasil membuat order, tetapi respons timeout di sisi klien. - Klien melakukan retry karena mengira request gagal.
- Server memproses retry sebagai request baru dan membuat order kedua.
Masalah ini tidak selalu bisa diselesaikan dengan validasi biasa, karena kedua request bisa sama-sama valid. Yang dibutuhkan adalah cara untuk mengatakan: “Jika ini retry dari operasi yang sama, jangan eksekusi side effect lagi.”
Pola idempotency store bekerja dengan menyimpan rekam jejak request pertama berdasarkan key yang diberikan klien. Jika request dengan key yang sama datang lagi:
- jika payload sama dan proses pertama sudah selesai, server mengembalikan respons pertama;
- jika payload berbeda, server menolak karena key dipakai ulang secara tidak konsisten;
- jika proses pertama masih berjalan, server dapat mengembalikan status konflik sementara.
Kontrak API yang Perlu Ditetapkan
Kapan klien wajib mengirim Idempotency-Key
Jangan mewajibkan Idempotency-Key untuk semua endpoint. Terapkan pada endpoint non-idempotent yang membuat side effect, terutama:
POST /ordersPOST /chargesPOST /checkout/submit
Untuk endpoint seperti itu, lebih aman jika key bersifat wajib. Jika tidak ada key, server dapat mengembalikan 422 dengan pesan bahwa header wajib dikirim. Pendekatan ini memaksa klien mobile, frontend, atau service lain untuk ikut menjaga konsistensi retry.
Jika endpoint hanya membaca data atau sudah idempotent secara alami, Anda biasanya tidak perlu menambahkan idempotency store.
Scope key: jangan global, gunakan konteks yang tepat
Idempotency-Key tidak boleh dianggap unik secara global tanpa konteks. Scope yang umum dan aman adalah gabungan dari:
- identitas caller (mis. user ID, merchant ID, API client ID),
- route atau nama operasi (mis.
POST:/orders), - key mentah dari klien.
Contoh scope:
scope = user_id + ':' + route_name + ':' + idempotency_keyMengapa demikian:
- user A dan user B boleh saja memakai key yang sama tanpa bentrok;
- key untuk
/orderstidak boleh otomatis berlaku untuk/charges; - key yang sama untuk operasi berbeda harus dianggap berbeda.
TTL: berapa lama key disimpan
TTL harus cukup lama untuk menutup jendela retry realistis, tetapi tidak terlalu lama hingga storage penuh dan debugging sulit. Untuk endpoint order/charge, praktik umum adalah menyimpan selama beberapa jam hingga beberapa hari, tergantung pola retry dan kebutuhan audit.
Prinsipnya:
- terlalu pendek: retry setelah timeout bisa lolos sebagai request baru;
- terlalu panjang: key lama menumpuk dan reuse tidak disengaja lebih mungkin terjadi.
Jika belum punya kebutuhan khusus, tentukan TTL eksplisit per operasi dan dokumentasikan di API. Jangan membiarkan perilaku TTL implisit atau berbeda-beda tanpa alasan.
Payload fingerprint: key yang sama harus untuk request yang sama
Key yang sama tidak boleh dipakai untuk payload berbeda. Karena itu, simpan juga fingerprint payload, misalnya hash dari representasi request yang sudah dinormalisasi.
Fingerprint sebaiknya dihitung dari field yang benar-benar relevan terhadap operasi. Contoh untuk POST /charges:
- amount
- currency
- payment_method_id
- order_reference
Jangan sertakan field yang berubah-ubah tetapi tidak memengaruhi makna request, seperti urutan key JSON mentah atau metadata tracing. Tujuannya adalah memastikan retry identik dikenali sebagai operasi yang sama.
Jika key sama tetapi fingerprint berbeda, kembalikan 422. Ini bukan konflik proses, melainkan penggunaan key yang salah oleh klien.
Respons pertama vs replay
Untuk request pertama yang berhasil diproses, simpan minimal:
- status respons HTTP,
- body respons,
- opsional: header yang relevan untuk replay.
Pada retry dengan key dan fingerprint yang sama, server mengembalikan respons pertama, bukan menghitung ulang hasil baru. Ini penting agar perilaku retry deterministik dari sudut pandang klien.
Jika respons pertama adalah 201 Created dengan body order yang terbentuk, replay juga sebaiknya mengembalikan 201 dan body yang sama. Jangan mengembalikan bentuk respons lain hanya karena request itu adalah retry.
Kapan mengembalikan 409 atau 422
Pemisahan ini penting agar klien tahu apa yang harus dilakukan.
- 422 Unprocessable Entity:
Idempotency-Keytidak ada padahal wajib, format key tidak valid, atau key yang sama dipakai dengan payload fingerprint berbeda. - 409 Conflict: request dengan key dan fingerprint yang sama sedang diproses oleh request lain, sehingga hasil final belum tersedia untuk direplay.
Dengan kata lain, 422 menandakan masalah kontrak request, sedangkan 409 menandakan konflik state sementara.
Desain Data untuk Idempotency Store
Struktur record yang disimpan
Terlepas dari apakah Anda memakai database atau Redis, data minimal yang perlu disimpan biasanya meliputi:
scope_key: kombinasi caller + route + idempotency key,idempotency_key: key asli dari klien,fingerprint: hash payload yang dinormalisasi,status:processing,completed, ataufailedsesuai kebutuhan,response_status,response_body,locked_untilatau TTL/expiry,created_at,updated_at.
Beberapa tim juga menambahkan referensi hasil bisnis, misalnya resource_type=order dan resource_id, agar investigasi lebih mudah.
Contoh skema tabel database
Jika ingin penyimpanan yang mudah diaudit dan queryable, database relasional cocok. Buat unique index pada scope_key.
Schema::create('idempotency_keys', function ($table) {
$table->id();
$table->string('scope_key')->unique();
$table->string('idempotency_key');
$table->string('fingerprint', 64);
$table->string('status', 20); // processing, completed, failed
$table->unsignedSmallInteger('response_status')->nullable();
$table->longText('response_body')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('created_at')->nullable();
$table->timestamp('updated_at')->nullable();
});Kelebihan database:
- lebih mudah untuk audit dan forensik,
- durable untuk kasus penting seperti pembayaran atau order,
- bisa ikut transaksi database aplikasi.
Kekurangannya:
- latensi umumnya lebih tinggi dibanding Redis,
- perlu perhatian ekstra pada lock dan unique constraint saat trafik tinggi.
Contoh struktur Redis
Jika prioritas Anda adalah kecepatan dan TTL alami, Redis sangat cocok. Misalnya simpan key seperti:
idem:{scope_key}Dengan value JSON:
{
"fingerprint": "...",
"status": "processing",
"response_status": 201,
"response_body": "{...}",
"expires_at": "..."
}Kelebihan Redis:
- operasi atomik sederhana,
- TTL mudah dikelola,
- baik untuk volume request tinggi.
Kekurangannya:
- audit lebih terbatas dibanding database,
- perlu perhatian pada durability jika data sangat kritikal.
Untuk endpoint finansial atau order yang sensitif, banyak tim memilih database sebagai source of truth, Redis sebagai akselerator opsional.
Strategi Lock Atomik untuk Menghindari Race Condition
Kasus paling berbahaya adalah dua request identik datang hampir bersamaan dengan key yang sama. Jika keduanya lolos pengecekan sebelum salah satunya menulis record, side effect bisa terjadi dua kali. Karena itu, Anda butuh claim atomik atas key tersebut.
Pendekatan database: unique constraint + insert pertama menang
Dengan unique index pada scope_key, Anda bisa mencoba membuat record processing. Hanya satu request yang akan berhasil. Request lain akan menerima kegagalan unique constraint, lalu membaca record yang sudah ada:
- jika fingerprint berbeda ->
422, - jika status
completed-> replay respons, - jika status
processing->409.
Pendekatan ini sederhana dan cukup kuat, asalkan penulisan awal memang dilakukan sebelum side effect bisnis.
Pendekatan Redis: SET NX dengan TTL
Di Redis, pola yang umum adalah SET key value NX EX ttl. Artinya, hanya request pertama yang bisa membuat key. Request berikutnya akan tahu bahwa proses sedang atau sudah pernah berjalan berdasarkan value yang disimpan.
Namun, Anda tetap perlu mendesain transisi state dengan hati-hati:
- saat claim awal, status =
processing; - setelah sukses, ubah menjadi
completeddan simpan respons; - jika proses crash di tengah jalan, pastikan status
processingtidak menggantung terlalu lama tanpa TTL.
Implementasi Middleware Laravel
Berikut contoh middleware yang fokus pada alur inti. Ini bukan implementasi final untuk semua kasus, tetapi cukup realistis untuk dijadikan dasar.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
use App\Models\IdempotencyKey;
class EnforceIdempotency
{
public function handle(Request $request, Closure $next): Response
{
if (!in_array($request->method(), ['POST'])) {
return $next($request);
}
$idempotencyKey = $request->header('Idempotency-Key');
if (!$idempotencyKey) {
return response()->json([
'message' => 'Idempotency-Key header is required.'
], 422);
}
$userId = optional($request->user())->getAuthIdentifier() ?? 'guest';
$routeScope = $request->method() . ':' . ($request->route()?->getName() ?? $request->path());
$scopeKey = hash('sha256', $userId . '|' . $routeScope . '|' . $idempotencyKey);
$fingerprint = $this->fingerprint($request);
try {
$record = IdempotencyKey::create([
'scope_key' => $scopeKey,
'idempotency_key' => $idempotencyKey,
'fingerprint' => $fingerprint,
'status' => 'processing',
'expires_at' => now()->addHours(24),
]);
} catch (\Illuminate\Database\QueryException $e) {
$record = IdempotencyKey::where('scope_key', $scopeKey)->first();
if (!$record) {
throw $e;
}
if ($record->fingerprint !== $fingerprint) {
return response()->json([
'message' => 'Idempotency-Key already used with different payload.'
], 422);
}
if ($record->status === 'completed') {
return response($record->response_body, $record->response_status)
->header('Content-Type', 'application/json')
->header('X-Idempotent-Replay', 'true');
}
return response()->json([
'message' => 'Request with the same Idempotency-Key is still being processed.'
], 409);
}
$response = $next($request);
$record->update([
'status' => 'completed',
'response_status' => $response->getStatusCode(),
'response_body' => $response->getContent(),
]);
return $response;
}
protected function fingerprint(Request $request): string
{
$payload = $request->only([
'amount',
'currency',
'payment_method_id',
'order_reference',
'items',
'customer_id',
]);
return hash('sha256', json_encode($this->sortRecursive($payload)));
}
protected function sortRecursive($value)
{
if (!is_array($value)) {
return $value;
}
foreach ($value as $k => $v) {
$v = $this->sortRecursive($v);
}
ksort($value);
return $value;
}
}Ada beberapa hal penting dari contoh di atas:
- record
processingdibuat sebelum controller mengeksekusi side effect; - duplicate request tidak langsung dianggap salah, tetapi diperiksa apakah fingerprint-nya sama;
- respons pertama disimpan dan dipakai untuk replay;
- request paralel kedua mendapat
409jika proses pertama belum selesai.
Registrasi middleware
Terapkan middleware ini hanya ke endpoint yang memang membutuhkan idempotency.
Route::post('/orders', [OrderController::class, 'store'])
->name('orders.store')
->middleware('idempotency');
Route::post('/charges', [ChargeController::class, 'store'])
->name('charges.store')
->middleware('idempotency');Catatan penting: simpan hanya respons yang layak direplay
Tidak semua respons perlu disimpan selamanya. Fokus utama adalah respons final dari operasi yang punya side effect. Jika body sangat besar, Anda bisa menyimpan bentuk ringkas atau referensi resource yang dibuat, lalu membangun ulang respons replay secara deterministik. Trade-off-nya: implementasi lebih rumit, tetapi storage lebih hemat.
Edge Case yang Sering Terjadi
Retry karena timeout, padahal proses pertama sukses
Ini alasan utama idempotency store ada. Misalnya request pertama berhasil membuat order, tetapi koneksi ke klien terputus sebelum respons diterima. Saat klien mengirim retry dengan key yang sama:
- server menemukan record
completed, - fingerprint cocok,
- server me-replay respons pertama.
Dari sisi klien, operasi tampak aman untuk diulang tanpa membuat order kedua.
Dua request identik datang bersamaan
Di sinilah lock atomik berperan. Request pertama berhasil membuat record processing. Request kedua menemukan bahwa key sedang diproses.
Opsi perilaku:
- kembalikan
409agar klien retry beberapa saat lagi; - atau lakukan polling singkat di server, lalu replay jika proses pertama sudah selesai.
Pendekatan 409 biasanya lebih sederhana dan jelas, terutama pada API publik atau antar-service.
Kegagalan parsial setelah side effect eksternal
Ini kasus paling sulit. Contohnya:
- server berhasil memanggil provider pembayaran dan charge sudah tercipta,
- tetapi aplikasi crash sebelum record idempotency di-update menjadi
completed.
Akibatnya, key bisa tertinggal di status processing padahal side effect eksternal sudah terjadi.
Untuk kasus ini, ada beberapa strategi:
- simpan state idempotency lebih awal dan update setelah tiap tahap penting;
- simpan referensi eksternal seperti
provider_charge_idagar recovery bisa dilakukan; - gunakan operasi eksternal yang juga mendukung idempotency jika provider menyediakannya;
- buat mekanisme recovery/manual reconciliation untuk status
processingyang terlalu lama.
Jangan mengasumsikan bahwa transaksi database lokal bisa membatalkan side effect di sistem eksternal. Transaksi lokal hanya melindungi database Anda, bukan dunia luar.
Untuk operasi yang menyentuh sistem eksternal, idempotency store adalah satu lapisan pengaman. Ia bukan pengganti strategi rekonsiliasi dan observabilitas.
Kapan Menyimpan Status Failed?
Tidak semua kegagalan perlu diperlakukan sama. Pertanyaan pentingnya: apakah side effect mungkin sudah terjadi?
- Jika validasi gagal sebelum side effect apa pun, biasanya tidak perlu menyimpan respons final sebagai replay jangka panjang. Klien cukup memperbaiki request.
- Jika kegagalan terjadi setelah proses parsial atau setelah memanggil sistem eksternal, menyimpan status gagal bisa membantu mencegah eksekusi ganda yang berbahaya.
Dalam praktiknya, banyak implementasi memisahkan:
- client error murni seperti validasi - tidak perlu dianggap idempotent final;
- hasil final operasi baik sukses maupun gagal setelah proses berjalan - layak disimpan untuk replay atau investigasi.
Yang penting adalah konsistensi aturan ini dan dokumentasi yang jelas ke pengguna API.
Pengujian Feature dan Concurrency
Feature test untuk replay
Pastikan request kedua dengan key dan payload yang sama tidak membuat resource baru.
public function test_same_key_and_same_payload_replays_first_response(): void
{
$headers = ['Idempotency-Key' => 'order-123'];
$payload = [
'customer_id' => 10,
'items' => [
['sku' => 'ABC', 'qty' => 1]
]
];
$first = $this->postJson('/api/orders', $payload, $headers);
$second = $this->postJson('/api/orders', $payload, $headers);
$first->assertStatus(201);
$second->assertStatus(201);
$this->assertEquals($first->json('id'), $second->json('id'));
$this->assertDatabaseCount('orders', 1);
}Feature test untuk fingerprint mismatch
public function test_same_key_with_different_payload_returns_422(): void
{
$headers = ['Idempotency-Key' => 'charge-001'];
$this->postJson('/api/charges', [
'amount' => 10000,
'currency' => 'IDR',
'payment_method_id' => 'pm_1'
], $headers)->assertStatus(201);
$this->postJson('/api/charges', [
'amount' => 20000,
'currency' =&t; 'IDR',
'payment_method_id' => 'pm_1'
], $headers)->assertStatus(422);
}Perhatikan bahwa inti pengujian bukan hanya status code, tetapi juga memastikan resource tidak tercipta dua kali.
Concurrency test
Pengujian konkurensi lebih sulit karena test HTTP biasa sering berjalan berurutan. Beberapa pendekatan yang bisa dipakai:
- jalankan dua proses test atau dua worker yang menembak endpoint yang sama secara paralel;
- buat endpoint uji yang sengaja menunda eksekusi beberapa detik agar jendela race condition terbuka;
- assert bahwa hanya satu request yang berhasil meng-claim key, dan request lain mendapat
409atau replay sesuai timing.
Jika Anda menjalankan pengujian integrasi di CI, concurrency test sering lebih andal bila dibuat sebagai test terpisah yang memukul environment uji nyata dengan Redis/database yang sama seperti produksi.
Debugging dan Observabilitas
Idempotency lebih mudah dioperasikan jika Anda menambahkan data observabilitas yang tepat:
- log
Idempotency-Key,scope_key, dan hasil keputusan middleware; - catat apakah respons adalah fresh atau replay;
- monitor jumlah
409dan422terkait idempotency; - buat alert untuk record
processingyang melewati ambang waktu tertentu.
Header tambahan seperti X-Idempotent-Replay: true juga berguna untuk debugging klien dan gateway.
Checklist Implementasi Produksi
- Tentukan endpoint mana saja yang wajib menerima
Idempotency-Key. - Definisikan scope key: minimal caller + route/operasi + key mentah.
- Tentukan aturan fingerprint payload yang stabil dan terdokumentasi.
- Pilih backend store: database untuk durability/audit, Redis untuk performa/TTL cepat, atau kombinasi keduanya.
- Pastikan ada mekanisme claim atomik: unique constraint atau Redis
NX. - Buat transisi state yang jelas:
processing->completeddan jika perlufailed. - Simpan respons pertama yang akan direplay secara deterministik.
- Putuskan aturan status code:
422untuk kontrak salah,409untuk request identik yang masih berjalan. - Tentukan TTL per operasi dan siapkan pembersihan data kadaluarsa.
- Tambahkan logging, metrics, dan alarm untuk record menggantung.
- Uji replay, mismatch, dan concurrency sebelum rilis.
- Siapkan prosedur recovery untuk kegagalan parsial setelah side effect eksternal.
Kesalahan Umum
- Menggunakan key tanpa scope, sehingga user berbeda bisa saling bentrok.
- Tidak memverifikasi fingerprint, sehingga key yang sama bisa dipakai untuk payload berbeda.
- Menyimpan record setelah side effect, yang membuka celah race condition dan double submit.
- Menganggap transaksi database cukup untuk operasi yang memanggil sistem eksternal.
- TTL terlalu singkat, sehingga retry sah malah diproses ulang sebagai operasi baru.
- Replay respons tidak konsisten, misalnya request pertama
201tetapi replay200dengan body berbeda. - Memasang middleware ke semua endpoint tanpa kebutuhan nyata, sehingga kompleksitas bertambah tanpa manfaat.
Penutup
Untuk Laravel API yang menangani pembuatan order, charge, atau operasi POST dengan side effect, pola idempotency store adalah cara praktis untuk mencegah double submit tanpa mengorbankan kemampuan retry. Kunci keberhasilannya bukan hanya menyimpan key, tetapi mendefinisikan kontrak API yang tegas: kapan key wajib dikirim, bagaimana scope dan fingerprint dihitung, bagaimana lock atomik bekerja, dan bagaimana respons pertama direplay secara konsisten.
Jika Anda menerapkannya dengan benar, retry karena timeout tidak lagi berisiko membuat data ganda, request paralel bisa dikendalikan, dan debugging produksi menjadi jauh lebih jelas. Untuk endpoint kritis, idempotency bukan fitur tambahan, melainkan bagian dari desain API itu sendiri.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!