Pada aplikasi monolit modern berbasis Inertia.js, interaksi form submission terasa secepat Single Page Application (SPA). Namun, ilusi responsivitas ini sering memicu masalah integritas data ketika mengeksekusi mutasi sensitif seperti transfer saldo atau pemotongan kuota. Latensi jaringan sesaat, tombol UI yang diklik berulang kali sebelum state processing aktif, atau pengiriman ulang request oleh proxy dapat memicu race condition yang berujung pada eksekusi ganda (double submit).
Mengandalkan proteksi antarmuka seperti disabled="form.processing" tidak memadai. Pemutusan event click di frontend dapat dilewati dengan mudah melalui DevTools, script console, atau micro-delay rendering engine framework. Solusi deterministik di level arsitektur adalah menerapkan pola Idempotency Key menggunakan distributed atomic lock di sisi backend.
Arsitektur Idempotensi Monolit Inertia
Pola idempotensi memastikan bahwa request yang identik dengan token referensi yang sama hanya diproses tepat satu kali (exactly-once execution). Permintaan duplikat berikutnya akan mengembalikan response yang sama tanpa memicu efek samping (side effects) pada basis data.
Alur kerja idempotensi pada stack Laravel dan Inertia:
- Frontend membangkitkan UUID v4 unik untuk setiap siklus mutasi form baru.
- UUID dikirimkan melalui HTTP header
Idempotency-Key. - Middleware backend memverifikasi payload hash dan status pemrosesan di Redis secara atomik (
NX). - Jika request sedang berjalan, tolak request konkuren dengan HTTP 409 Conflict.
- Jika request telah selesai pada siklus sebelumnya, replay response yang tersimpan di Redis tanpa menyentuh controller logic.
Implementasi Frontend: useForm dan Idempotency Header
Kunci idempotensi harus diikat ke dalam siklus hidup form. Kunci ini hanya boleh di-regenerasi ketika form berhasil disubmit atau di-reset secara eksplisit oleh pengguna, bukan saat validasi server gagal.
Contoh Implementasi pada Vue 3
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
const idempotencyKey = ref(crypto.randomUUID());
const form = useForm({
recipient_account: '',
amount: 0,
notes: '',
});
const submitTransfer = () => {
form.post('/transfers', {
headers: {
'Idempotency-Key': idempotencyKey.value,
},
onSuccess: () => {
// Regenerasi token hanya setelah transaksi berhasil
idempotencyKey.value = crypto.randomUUID();
form.reset();
},
onError: () => {
// Pertahankan token lama jika error validasi,
// agar user dapat mengoreksi input tanpa memicu race-key baru
},
});
};
</script>Implementasi Backend: Middleware Idempotensi Laravel & Redis
Middleware backend bertugas menangani tiga hal: mengunci eksekusi konkuren, memvalidasi integritas payload, dan menyimpan serialisasi response untuk diputar ulang (safe replay).
Middleware: EnsureIdempotency
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
class EnsureIdempotency
{
public function handle(Request $request, Closure $next): Response
{
$key = $request->header('Idempotency-Key');
if (!$key || $request->isMethodSafe()) {
return $next($request);
}
$userId = $request->user()?->getAuthIdentifier() ?? 'guest';
$storageKey = "idempotency:response:{$userId}:{$key}";
$lockKey = "idempotency:lock:{$userId}:{$key}";
$payloadHash = hash('sha256', $request->path() . '|' . json_encode($request->all()));
// 1. Periksa apakah response sudah tersimpan sebelumnya
if ($cachedResponse = Redis::get($storageKey)) {
$payload = json_decode($cachedResponse, true);
if ($payload['hash'] !== $payloadHash) {
return response()->json([
'message' => 'Idempotency key reuse with mismatched payload.'
], 422);
}
return response($payload['content'], $payload['status'], $payload['headers'])
->header('X-Idempotency-Replay', 'true');
}
// 2. Akuisisi Redis Atomic Lock (NX: Set if Not Exists, EX: Expire 60s)
$acquired = Redis::set($lockKey, 'in_progress', 'EX', 60, 'NX');
if (!$acquired) {
// Konflik konkurensi (request pertama masih dieksekusi)
return response()->json([
'message' => 'Permintaan sedang diproses. Mohon tidak mengirim ulang.'
], 409);
}
try {
$response = $next($request);
// 3. Simpan response sukses atau redirect ke Redis (TTL 24 jam)
if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 400) {
$headers = $response->headers->all();
unset($headers['set-cookie']); // Cegah overwrite session/cookie lama
$cacheData = [
'hash' => $payloadHash,
'status' => $response->getStatusCode(),
'headers' => $headers,
'content' => $response->getContent(),
];
Redis::setex($storageKey, 86400, json_encode($cacheData));
}
return $response;
} finally {
// Hapus lock in-flight
Redis::del($lockKey);
}
}
}Penanganan HTTP 409 Conflict di Inertia.js
Inertia secara default akan memicu modal error dialog jika backend merespons dengan status selain 200, 302, 303, atau 422. Agar respon HTTP 409 tertangani secara elegan tanpa crash pada UI, pasang interceptor global di file bootstrap frontend (app.js):
import { router } from '@inertiajs/vue3';
router.on('invalid', (event) => {
if (event.detail.response.status === 409) {
event.preventDefault();
// Notifikasi non-blocking (toast/alert) kepada user
console.warn('Permintaan terduplikasi dicegah oleh server.');
}
});Verifikasi dan Pengujian Konkurensi
1. Pengujian Konkurensi Menggunakan cURL
Gunakan subshell bash untuk menembakkan lima request POST paralel secara bersamaan dengan token idempotensi yang identik:
TOKEN=$(uuidgen)
ENDPOINT="http://localhost:8000/transfers"
COOKIE="laravel_session=YOUR_SESSION_COOKIE"
for i in {1..5}; do
curl -s -o /dev/null -w "%{http_code}\n" -X POST $ENDPOINT \
-H "Idempotency-Key: $TOKEN" \
-H "Content-Type: application/json" \
-H "Cookie: $COOKIE" \
-d '{"recipient_account": "12345", "amount": 50000}' &
done
waitHasil terminal akan menampilkan status satu HTTP 200/302, sementara empat request lainnya menghasilkan HTTP 409.
2. Unit/Feature Test di Laravel
public function test_concurrent_idempotent_requests_are_blocked(): void
{
$user = User::factory()->create();
$key = (string) \Illuminate\Support\Str::uuid();
// Simulasikan request pertama sedang memegang lock
\Illuminate\Support\Facades\Redis::set("idempotency:lock:{$user->id}:{$key}", 'in_progress', 'EX', 10, 'NX');
$response = $this->actingAs($user)
->withHeaders(['Idempotency-Key' => $key])
->postJson('/transfers', [
'recipient_account' => '12345',
'amount' => 50000,
]);
$response->assertStatus(409);
}Trade-offs dan Pertimbangan Produksi
- TTL Response Cache: Simpan response selama 24 hingga 48 jam. Menyimpan lebih lama dari itu membebani memori Redis untuk data mutasi yang state-nya sudah final.
- Safe Replay vs Flash Messages: Pada Inertia, response replay akan mengembalikan snapshot props Inertia yang sama persis. Pastikan flash session tidak ter-duplicate secara membingungkan.
- Uncaught Exception: Blok
finallypada middleware menjamin Redis lock selalu terhapus bahkan jika aplikasi mengalami unhandled runtime error (HTTP 500).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!