Kegagalan jaringan setelah mutasi data di backend berhasil dieksekusi adalah salah satu skenario paling rentan pada aplikasi web modern. Pada aplikasi berbasis Inertia.js, request mutasi (POST, PUT, PATCH, DELETE) yang dikirim melalui router.visit atau useForm dapat mengalami network timeout sebelum browser menerima respons dari server, meskipun database transaksi di backend telah selesai di-commit.
Ketika browser atau reverse proxy (seperti Nginx atau Cloudflare) mengembalikan error 504 Gateway Timeout, indikator UI sering kali menunjukkan kegagalan transaksi. Pengguna secara naluriah akan menekan tombol submit kembali. Tanpa mekanisme idempotensi, retry tersebut akan mengeksekusi logika domain untuk kedua kalinya, menyebabkan masalah kritis seperti double-charging atau duplikasi pesanan. Implementasi Idempotency Key menyelesaikan masalah ini dengan memastikan sebuah operasi hanya dieksekusi tepat satu kali (exactly-once execution semantics) terlepas dari berapa kali request diulang.
Anatomi Masalah: Network Timeout pada Inertia Visit
Inertia.js bekerja dengan mengirimkan XMLHttpRequest (XHR) atau fetch request dengan header X-Inertia: true. Backend umumnya merespons dengan HTTP redirect (303 See Other) menuju halaman tujuan atau mengembalikan JSON payload yang berisi props halaman baru.
Siklus kegagalan timeout terjadi dalam urutan berikut:
- Client mengirimkan
router.post('/checkout', payload). - Backend menerima request, membuka database transaction, mendebit saldo, membuat record pesanan, dan meng-commit transaksi.
- Backend menyiapkan redirect response ke
/orders/101. - Koneksi TCP terputus di tengah jalan antara reverse proxy dan client akibat fluktuasi jaringan seluler atau timeout gateway.
- Client menerima status
ERR_CONNECTION_TIMED_OUTatau HTTP 504. State form lokal tetap aktif, dan error callback terpicu. - User menekan kembali tombol checkout. Request kedua terkirim dengan payload yang sama persis, mendebit saldo untuk kedua kalinya.
Catatan: Menonaktifkan tombol submit di UI (button disabling) hanya mencegah double-click lokal dalam hitungan milidetik. Cara ini tidak dapat mencegah retry saat koneksi internet benar-benar terputus dan user me-refresh halaman atau mencoba ulang beberapa detik kemudian.
Client-Side: Menyuntikkan Header Idempotency-Key
Idempotency key harus bersifat unik per aksi bisnis, bukan per HTTP attempt. Artinya, jika sebuah request gagal karena network error dan dicoba ulang, key yang dikirimkan harus tetap sama. Sebaliknya, jika user sengaja membatalkan dan membuat pesanan baru, key baru harus di-generate.
Gunakan crypto.randomUUID() standar browser untuk menghasilkan UUID v4 pada level form state.
Contoh Implementasi pada Vue 3
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
const idempotencyKey = ref(crypto.randomUUID());
const form = useForm({
item_id: 42,
quantity: 1,
});
const submitOrder = () => {
form.post('/orders', {
headers: {
'Idempotency-Key': idempotencyKey.value,
},
onSuccess: () => {
// Regenerasi key hanya ketika transaksi telah tuntas
idempotencyKey.value = crypto.randomUUID();
},
onError: (errors) => {
// Jika validasi domain gagal, pertahankan key yang sama untuk perbaikan input,
// atau reset jika arsitektur menganggap perbaikan form adalah transaksi baru.
},
});
};
</script>Jika ingin menerapkan idempotency key secara otomatis pada semua mutasi non-idempoten tanpa menulis manual di setiap komponen, gunakan global visit listener Inertia:
import { router } from '@inertiajs/vue3';
router.on('before', (event) => {
const method = event.detail.visit.method.toLowerCase();
if (['post', 'put', 'patch', 'delete'].includes(method)) {
const existingKey = event.detail.visit.headers['Idempotency-Key'];
if (!existingKey) {
event.detail.visit.headers['Idempotency-Key'] = crypto.randomUUID();
}
}
});Backend Architecture: Middleware Validasi & Locking
Di backend (contoh menggunakan Laravel), middleware bertugas mengintersepsi request sebelum mencapai controller, mengelola race conditions menggunakan atomic distributed lock, dan mengembalikan snapshot response jika request telah selesai diproses sebelumnya.
Struktur Alur Middleware:
- Ekstraksi Key: Baca header
Idempotency-Key. Jika tidak ada pada method mutasi, putuskan apakah request ditolak (HTTP 400) atau dilewati (opsional tergantung kebutuhan strictness). - Payload Fingerprinting: Buat hash (SHA-256) dari method, path URI, ID user yang terotentikasi, dan isi payload. Tujuannya mendeteksi penggunaan ulang key yang sama dengan data yang berbeda.
- Atomic Lock: Dapatkan atomic lock di Redis dengan key berbasis idempotency key. Jika lock gagal didapat, berarti request sedang diproses (in-flight), kembalikan HTTP 409 Conflict.
- Cache Hit Verification: Periksa apakah response dari key ini sudah tersimpan. Jika ada, verifikasi hash payload. Jika hash cocok, kembalikan snapshot response. Jika hash berbeda, lempar HTTP 422 Unprocessable Entity.
- Eksekusi Controller: Jika key belum pernah diproses, eksekusi pipeline berikutnya (
$next($request)). - Simpan Snapshot: Simpan response status, header krusial, dan content ke dalam cache sebelum melepas lock.
Implementasi Middleware Laravel
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
class EnsureRequestIdempotency
{
public function handle(Request $request, Closure $next): Response
{
$key = $request->header('Idempotency-Key');
if (! $key || ! in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
return $next($request);
}
$userId = $request->user()?->getAuthIdentifier() ?? 'guest';
$cacheKey = "idempotency:{$userId}:{$key}";
$lockKey = "idempotency_lock:{$userId}:{$key}";
// Fingerprint payload untuk mencegah parameter tampering dengan key yang sama
$payloadChecksum = hash('sha256', json_encode([
'method' => $request->method(),
'uri' => $request->path(),
'body' => $request->all(),
]));
// 1. Tangani request in-flight menggunakan Redis Atomic Lock (TTL 15 detik)
$lock = Cache::lock($lockKey, 15);
if (! $lock->get()) {
return response()->json([
'message' => 'Request sedang diproses. Silakan tunggu.',
], Response::HTTP_CONFLICT);
}
try {
// 2. Evaluasi apakah response sudah ada di cache
$cached = Cache::get($cacheKey);
if ($cached) {
if ($cached['checksum'] !== $payloadChecksum) {
return response()->json([
'message' => 'Idempotency Key telah digunakan untuk payload yang berbeda.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
// Replikasi response snapshot transparan
return response($cached['content'], $cached['status'])
->withHeaders($cached['headers']);
}
// 3. Eksekusi domain logic
$response = $next($request);
// Hanya cache response sukses atau redirect khas Inertia (200 - 303)
if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 400) {
Cache::put($cacheKey, [
'checksum' => $payloadChecksum,
'status' => $response->getStatusCode(),
'content' => $response->getContent(),
'headers' => array_filter($response->headers->all(), function ($headerName) {
return in_array(strtolower($headerName), [
'content-type',
'x-inertia',
'x-inertia-location',
'location',
]);
}, ARRAY_FILTER_USE_KEY),
], now()->addHours(24));
}
return $response;
} finally {
$lock->release();
}
}
}Edge Cases dan Strategi Penyelesaian
1. Permintaan Sedang Berjalan (HTTP 409 Conflict)
Jika pengguna menekan tombol kirim berkali-kali secara agresif dalam interval milidetik, request kedua akan tiba saat request pertama masih memegang atomic lock di Redis. Server harus mengembalikan HTTP 409 Conflict daripada membiarkan proses kedua mengantre dan berisiko mengalami deadlock atau duplikasi execution race.
Di sisi Inertia, router otomatis menangkap non-200 responses. Anda dapat menangani HTTP 409 secara terpusat melalui event global:
router.on('invalid', (event) => {
if (event.detail.response.status === 409) {
event.preventDefault();
alert('Permintaan Anda sedang diproses. Mohon tunggu sejenak sebelum mencoba kembali.');
}
});2. Payload Mismatch pada Identik Key
Kasus ini terjadi ketika client menggunakan kembali idempotency key yang sama, tetapi data formulirnya telah dimodifikasi (misalnya mengubah kuantitas barang dari 1 menjadi 5). Middleware mendeteksi perbedaan hash SHA-256 antara payload tersimpan dan payload baru, lalu memutus eksekusi dengan HTTP 422 Unprocessable Entity. Hal ini mencegah ambiguitas apakah server harus mengembalikan state lama atau mengeksekusi state baru.
3. Determinisme Props Inertia & Session Flash
Inertia sangat bergantung pada shared session flash data (seperti flash message 'Pesanan berhasil dibuat!'). Saat snapshot response dikembalikan dari cache, session Laravel aslinya mungkin sudah terhapus (flushed). Menyimpan header X-Inertia, status code (misal 303 redirect), dan header Location memastikan client diarahkan ke rute yang benar persis seperti eksekusi awal.
Strategi TTL dan Manajemen Storage Cache
Penyimpanan snapshot idempotency harus didesain secara efisien agar tidak membebani memori Redis:
- Durasi TTL (Time-to-Live): Berikan rentang 24 hingga 72 jam. Skenario network retry hampir selalu terjadi dalam kurun waktu beberapa detik hingga beberapa menit. Menyimpan lebih dari 72 jam hanya membuang RAM tanpa manfaat fungsional tambahan.
- Redis Memory Policy: Pastikan instance Redis dikonfigurasi dengan kebijakan evaporasi seperti
volatile-lruatauallkeys-lruuntuk menghindari memory exhaustion jika volume transaksi melonjak tajam. - Key Namespacing: Selalu sertakan User ID pada format cache key (
idempotency:{user_id}:{key}). Hal ini mencegah satu user memblokir atau membaca response transaksi milik user lain jika terjadi tabrakan UUID.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!