Pada endpoint seperti payment, create order, atau operasi tulis lain, masalah terbesar bukan hanya request gagal, tetapi request yang berhasil diproses lebih dari sekali. Penyebabnya sering sederhana: koneksi timeout, client melakukan retry otomatis, user menekan tombol dua kali, atau dua proses masuk hampir bersamaan. Tanpa mekanisme Idempotency-Key, API Laravel bisa membuat pembayaran ganda, order duplikat, atau status data yang tidak konsisten.
Laravel API Idempotency-Key adalah pola untuk memastikan satu intent bisnis hanya diproses sekali dalam scope tertentu. Client mengirim header unik, server menyimpan hasil proses pertama, lalu request berikutnya dengan key yang sama akan ditolak, ditunggu, atau dikembalikan dengan response yang sama, tergantung status eksekusinya. Pendekatan ini sangat cocok untuk endpoint write yang rawan retry, terutama payment dan order.
Apa masalah yang sebenarnya ingin diselesaikan?
Idempotency bukan sekadar mencegah tombol diklik dua kali. Ia melindungi sistem dari beberapa sumber duplikasi yang umum terjadi di production:
- Timeout di sisi client: server sebenarnya sudah memproses pembayaran, tetapi client tidak menerima response lalu mengirim ulang request.
- Retry otomatis: mobile app, gateway, proxy, atau job worker dapat melakukan retry ketika menerima status tertentu atau saat koneksi putus.
- Double submit: pengguna menekan tombol bayar dua kali karena UI lambat.
- Race condition: dua request identik masuk hampir bersamaan sebelum baris data sempat dibuat.
Jika endpoint hanya bergantung pada validasi biasa atau pengecekan sebelum insert tanpa lock, race condition tetap bisa lolos. Misalnya, dua request membaca bahwa belum ada order, lalu keduanya membuat order baru. Di sinilah idempotency perlu dikombinasikan dengan penyimpanan state dan strategi locking.
Kontrak API yang disarankan
Header Idempotency-Key
Untuk endpoint write seperti POST /payments atau POST /orders, minta client mengirim header:
Idempotency-Key: 01HV7M9N7J8Y4V8Y8GQ9P2XK1APrinsip kontraknya:
- Wajib untuk endpoint yang rawan duplikasi.
- Nilainya harus unik per intent bisnis, bukan per retry.
- Client harus memakai key yang sama saat mengulang request yang sama.
- Jika payload berubah tetapi key sama, server harus menolaknya.
Format key tidak harus spesifik, tetapi sebaiknya cukup panjang dan sulit ditebak. UUID atau ULID adalah pilihan aman.
Scope key: jangan global tanpa batas
Satu kesalahan desain yang sering terjadi adalah menganggap key unik secara global. Praktiknya, key sebaiknya discope agar lebih aman dan mudah dikelola. Contoh scope yang umum:
- Per user + route: cocok untuk API dengan autentikasi user.
- Per merchant/account + route: cocok untuk integrasi partner.
- Per actor + method + route: menghindari benturan antar endpoint.
Contoh identity yang disimpan:
scope = sha256(user_id + '|' + method + '|' + route_name + '|' + idempotency_key)Dengan ini, dua user berbeda bisa saja memakai key yang sama tanpa bentrok, karena scope final-nya berbeda.
Kapan response lama boleh dikembalikan?
Response lama aman dikembalikan jika:
- Request baru memiliki payload fingerprint yang sama.
- Request berada dalam scope yang sama.
- Eksekusi sebelumnya sudah selesai dan hasilnya final untuk intent tersebut.
Jika request pertama masih berjalan, ada dua pendekatan:
- Kembalikan 409 Conflict atau 425/429 bergantung kebijakan, lalu minta client retry nanti.
- Tunggu sebentar sampai proses pertama selesai, lalu kembalikan response yang sama.
Untuk API sinkron biasa, pendekatan pertama umumnya lebih sederhana dan aman.
Desain data: apa yang perlu disimpan?
Minimal, server perlu menyimpan metadata berikut:
- Scope key hasil gabungan actor/route/idempotency key.
- Nilai header
Idempotency-Key. - Fingerprint payload.
- Status pemrosesan:
processing,succeeded,failed. - HTTP status code response pertama.
- Body response pertama yang aman untuk direplay.
- Waktu kedaluwarsa atau TTL.
Contoh skema tabel relasional:
Schema::create('idempotency_keys', function ($table) {
$table->id();
$table->string('scope_key')->unique();
$table->string('idempotency_key', 255);
$table->string('actor_type', 50)->nullable();
$table->string('actor_id', 100)->nullable();
$table->string('method', 10);
$table->string('route', 255);
$table->string('request_fingerprint', 64);
$table->string('status', 20); // processing, succeeded, failed
$table->unsignedSmallInteger('response_code')->nullable();
$table->longText('response_body')->nullable();
$table->timestamp('locked_until')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});Jika kebutuhan latensi sangat ketat, Redis cocok untuk state cepat dan lock. Namun untuk kebutuhan audit, replay response, dan recovery setelah restart, database tetap berguna. Banyak sistem memakai kombinasi Redis untuk lock dan database untuk rekaman final.
Fingerprint request
Fingerprint dipakai untuk mendeteksi bahwa key yang sama tidak boleh digunakan untuk payload berbeda. Jangan hash body mentah tanpa normalisasi jika urutan field JSON bisa berubah. Lebih aman:
- Ambil field yang relevan secara bisnis.
- Susun urut secara konsisten.
- Encode JSON secara stabil.
- Hash hasil akhirnya, misalnya SHA-256.
Contoh field untuk payment:
fingerprint_data = [
'amount' => 150000,
'currency' => 'IDR',
'order_id' => 'ORD-123',
'payment_method' => 'va_bca'
]Jangan masukkan field yang memang berubah di tiap retry, seperti timestamp client, nonce presentasional, atau metadata yang tidak memengaruhi intent bisnis.
TTL: berapa lama key disimpan?
TTL bergantung pada karakter endpoint:
- Payment: biasanya lebih lama karena retry bisa terjadi setelah jeda jaringan atau proses settlement di klien.
- Create order internal: bisa lebih pendek jika intent cepat final.
- Callback partner: sesuaikan dengan pola retry partner, tetapi tetap jangan terlalu singkat.
Prinsipnya, TTL harus cukup panjang untuk menutup kemungkinan retry yang sah, tetapi tidak terlalu lama hingga menyulitkan pembersihan data. Jika ragu, pilih TTL konservatif lalu ukur dari log retry nyata di production.
Implementasi Laravel: middleware + service
Di Laravel, pola yang praktis adalah memisahkan tanggung jawab:
- Middleware: validasi header, bangun scope, cek record existing, pasang lock, dan simpan context request.
- Service/repository: update status sukses/gagal dan simpan response final.
- Business handler: tetap fokus pada logika payment/order.
Contoh middleware dasar
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
use App\Models\IdempotencyKey;
class EnsureIdempotency
{
public function handle(Request $request, Closure $next): Response
{
if (!in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
return $next($request);
}
$idempotencyKey = $request->header('Idempotency-Key');
if (!$idempotencyKey) {
return response()->json([
'message' => 'Idempotency-Key header is required.'
], 400);
}
$actorId = optional($request->user())->getAuthIdentifier() ?? 'guest';
$route = optional($request->route())->getName() ?? $request->path();
$method = $request->method();
$scopeRaw = $actorId . '|' . $method . '|' . $route . '|' . $idempotencyKey;
$scopeKey = hash('sha256', $scopeRaw);
$fingerprint = $this->fingerprint($request);
$lock = Cache::lock('idem:' . $scopeKey, 10);
if (!$lock->get()) {
return response()->json([
'message' => 'A request with the same Idempotency-Key is being processed.'
], 409);
}
try {
$record = IdempotencyKey::query()->where('scope_key', $scopeKey)->first();
if ($record) {
if ($record->request_fingerprint !== $fingerprint) {
return response()->json([
'message' => 'Idempotency-Key already used with different payload.'
], 422);
}
if ($record->status === 'succeeded' && $record->response_body) {
return response($record->response_body, $record->response_code)
->header('Content-Type', 'application/json')
->header('X-Idempotent-Replay', 'true');
}
if ($record->status === 'processing') {
return response()->json([
'message' => 'Original request is still processing.'
], 409);
}
} else {
IdempotencyKey::query()->create([
'scope_key' => $scopeKey,
'idempotency_key' => $idempotencyKey,
'actor_type' => $request->user() ? get_class($request->user()) : null,
'actor_id' => (string) $actorId,
'method' => $method,
'route' => $route,
'request_fingerprint' => $fingerprint,
'status' => 'processing',
'expires_at' => now()->addDay(),
]);
}
$request->attributes->set('idempotency.scope_key', $scopeKey);
$response = $next($request);
$this->persistResponse($scopeKey, $response);
return $response;
} catch (\Throwable $e) {
DB::table('idempotency_keys')
->where('scope_key', $scopeKey)
->update([
'status' => 'failed',
'updated_at' => now(),
]);
throw $e;
} finally {
optional($lock)->release();
}
}
protected function fingerprint(Request $request): string
{
$data = [
'amount' => $request->input('amount'),
'currency' => $request->input('currency'),
'order_id' => $request->input('order_id'),
'payment_method' => $request->input('payment_method'),
];
ksort($data);
return hash('sha256', json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
protected function persistResponse(string $scopeKey, $response): void
{
$status = $response->getStatusCode();
$body = method_exists($response, 'getContent') ? $response->getContent() : null;
DB::table('idempotency_keys')
->where('scope_key', $scopeKey)
->update([
'status' => $status >= 200 && $status < 500 ? 'succeeded' : 'failed',
'response_code' => $status,
'response_body' => $body,
'updated_at' => now(),
]);
}
}Contoh di atas sengaja sederhana. Di production, Anda biasanya akan memecah logika menjadi service terpisah agar lebih mudah dites dan diobservasi.
Mengapa middleware saja tidak cukup?
Middleware berguna untuk gerbang masuk, tetapi tidak menggantikan integritas data di level domain. Untuk payment atau order, tetap gunakan:
- Unique constraint pada kolom bisnis yang memang harus unik.
- Transaksi database untuk perubahan multi-tabel.
- Lock domain jika ada resource spesifik yang tidak boleh diproses paralel.
Idempotency adalah lapisan perlindungan terhadap retry dan replay. Ia bukan pengganti desain konsistensi data.
Strategi lock: Redis vs database
Redis lock
Redis cocok untuk mencegah dua request identik diproses bersamaan dengan latensi rendah. Kelebihannya:
- Cepat.
- Mudah dipakai untuk lock dengan TTL.
- Baik untuk beban tinggi.
Kekurangannya:
- Jika hanya menyimpan state di Redis, replay response bisa hilang saat restart atau eviction.
- Perlu disiplin TTL agar lock tidak tertinggal terlalu lama.
Database constraint + row state
Database memberikan durability lebih baik. Pendekatan umum:
- Buat unique index di
scope_key. - Coba insert record
processing. - Jika insert gagal karena duplicate key, baca record existing lalu putuskan replay atau tolak.
Kelebihannya adalah state lebih persisten dan audit lebih mudah. Kekurangannya, contention bisa lebih tinggi dibanding lock ringan di Redis.
Pilih yang mana?
Untuk banyak sistem Laravel, kombinasi berikut paling seimbang:
- Redis lock untuk mencegah eksekusi paralel yang rapat.
- Database record untuk status final, fingerprint, dan replay response.
Jika infrastruktur sederhana dan throughput belum tinggi, database saja sering sudah cukup asalkan unique constraint dan transaksi dirancang benar.
Status code yang masuk akal
Tidak ada satu standar tunggal yang wajib untuk semua implementasi, tetapi kebijakannya harus konsisten dan terdokumentasi.
- 400 Bad Request: header
Idempotency-Keywajib tetapi tidak dikirim. - 409 Conflict: request dengan key yang sama sedang diproses atau bentrok secara state.
- 422 Unprocessable Entity: key sama dipakai dengan payload berbeda.
- 200/201 replay response lama: request identik, proses sebelumnya sudah sukses, dan aman untuk mengembalikan hasil yang sama.
- 5xx: kegagalan server nyata. Hati-hati sebelum memutuskan apakah hasil 5xx akan direplay atau tidak.
Jika request pertama menghasilkan validasi bisnis yang deterministik, menyimpan dan mereplay response 4xx tertentu bisa masuk akal. Namun jangan gegabah mereplay semua error. Bedakan antara final business outcome dan kegagalan teknis sementara.
Skenario gagal parsial dan cara menanganinya
Kasus paling berbahaya adalah gagal parsial: misalnya API Anda sudah membuat transaksi lokal, lalu timeout ketika memanggil provider pembayaran, atau sebaliknya provider berhasil didebit tetapi API Anda gagal menyimpan status final. Idempotency-Key membantu, tetapi tidak menyelesaikan semuanya sendirian.
Prinsip penting
- Jika ada sistem eksternal, gunakan reference bisnis unik yang juga dikirim ke provider bila memungkinkan.
- Simpan status perantara seperti
pending, bukan memaksa langsungsucceeded. - Pada retry dengan key yang sama, lakukan reconciliation check ke state lokal atau provider sebelum memutuskan membuat transaksi baru.
Contoh alur aman:
- Insert record idempotency =
processing. - Buka transaksi DB, buat payment intent lokal dengan reference unik, commit.
- Panggil provider dengan reference yang sama.
- Jika provider sukses, update payment lokal menjadi sukses dan simpan response replay.
- Jika timeout saat langkah 3/4, tandai status lokal sebagai
pendingatauunknown. - Pada retry dengan key yang sama, baca status lokal dan cek reference ke provider sebelum membuat intent baru.
Poin utamanya: jangan menganggap retry setelah timeout selalu aman untuk mengeksekusi ulang side effect eksternal.
Validasi payload berubah dengan key sama
Ini aturan yang wajib. Jika client mengirim Idempotency-Key yang sama tetapi nominal pembayaran berubah, request harus ditolak. Kalau tidak, key kehilangan makna sebagai identitas intent.
Yang sering keliru adalah fingerprint mengambil seluruh body mentah, padahal beberapa field tidak relevan. Di sisi lain, terlalu sedikit field juga berbahaya karena dua intent berbeda bisa dianggap sama. Solusinya:
- Pilih field yang benar-benar mendefinisikan intent bisnis.
- Dokumentasikan field tersebut untuk tiap endpoint.
- Uji perubahan kecil yang seharusnya dianggap bentrok.
Pengujian concurrency di Laravel
Jangan berhenti di unit test tunggal. Idempotency justru sering gagal saat diuji paralel. Minimal, uji tiga skenario:
- Dua request identik masuk hampir bersamaan dengan key sama.
- Request kedua datang setelah request pertama sukses.
- Request kedua memakai key sama tetapi payload berbeda.
Contoh ide feature test
public function test_same_idempotency_key_returns_replayed_response()
{
$headers = ['Idempotency-Key' => 'test-key-123'];
$payload = [
'order_id' => 'ORD-1',
'amount' => 150000,
'currency' => 'IDR',
'payment_method' => 'va_bca',
];
$first = $this->postJson('/api/payments', $payload, $headers);
$second = $this->postJson('/api/payments', $payload, $headers);
$first->assertStatus(201);
$second->assertStatus($first->getStatusCode());
$second->assertHeader('X-Idempotent-Replay', 'true');
$this->assertSame($first->json('payment_id'), $second->json('payment_id'));
}Untuk uji paralel sungguhan, Anda bisa menembakkan request konkuren dari skrip terpisah atau tool load test. Tujuannya bukan mencari throughput, tetapi memastikan hanya satu side effect bisnis yang tercipta.
Apa yang harus diverifikasi saat test?
- Hanya ada satu row payment/order yang dibuat.
- Record idempotency tersimpan dengan status final yang benar.
- Request replay mengembalikan body dan status code yang konsisten.
- Payload berbeda dengan key sama ditolak.
- Request yang datang saat status
processingditangani sesuai kontrak.
Kesalahan umum
- Hanya cek key tanpa fingerprint payload: berbahaya karena key bisa dipakai ulang untuk intent berbeda.
- Mengandalkan cache tanpa durability: response replay hilang setelah restart.
- Tidak ada unique constraint: dua proses bisa insert record yang sama saat race condition.
- Mereplay semua error: hasil 5xx sementara bisa menyesatkan client.
- TTL terlalu pendek: retry sah datang setelah key sudah kadaluarsa lalu side effect terulang.
- Key discope global: meningkatkan risiko benturan yang tidak perlu.
Debugging dan observability
Idempotency sulit dianalisis tanpa log yang cukup. Minimal catat:
idempotency_keyscope_keyrequest_fingerprint- status transisi:
processingkesucceeded/failed - apakah response adalah replay
- reference bisnis seperti
order_idataupayment_id
Tambahkan metrik sederhana:
- jumlah replay sukses
- jumlah conflict saat processing
- jumlah payload mismatch
- jumlah key expired yang kemudian membuat side effect baru
Dari metrik ini, Anda bisa melihat apakah TTL terlalu pendek, client terlalu sering retry, atau ada bug pada pembentukan fingerprint.
Checklist produksi
- Header
Idempotency-Keywajib di endpoint write yang kritikal. - Scope key mencakup actor dan route/method.
- Fingerprint hanya memakai field bisnis yang relevan.
- Ada unique constraint pada
scope_key. - Ada lock untuk request yang masuk bersamaan.
- Status state jelas:
processing,succeeded,failed, ataupendingbila diperlukan. - Response sukses pertama disimpan untuk replay yang aman.
- Payload mismatch dengan key sama menghasilkan error yang konsisten.
- TTL ditentukan dan ada job cleanup untuk data kadaluarsa.
- Business entity tetap memiliki proteksi domain sendiri, misalnya unique reference.
- Logging dan metrik tersedia untuk investigasi.
- Concurrency test dijalankan sebelum rilis.
Penutup
Laravel API Idempotency-Key bukan fitur kosmetik untuk payment API, tetapi mekanisme penting agar retry tidak berubah menjadi duplikasi transaksi. Implementasi yang baik selalu punya empat komponen: kontrak header yang jelas, scope key yang tepat, fingerprint payload, dan state storage + lock yang mencegah race condition.
Jika Anda membangun endpoint payment, order, atau callback partner yang bisa di-retry, mulailah dari kontrak sederhana: satu key untuk satu intent, payload yang sama menghasilkan response yang sama, payload berbeda ditolak, dan request paralel tidak boleh menciptakan side effect ganda. Setelah itu, perkuat dengan transaksi database, unique reference bisnis, observability, dan pengujian concurrency.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!