Secara default, Inertia.js mengharapkan respons halaman yang valid dengan header X-Inertia. Ketika backend (seperti Laravel) memicu throttling via middleware throttle:api atau throttle:web, server mengirimkan respons HTTP 429 (Too Many Requests). Respons ini umumnya berupa payload teks atau JSON mentah tanpa header Inertia, menyebabkan Inertia menampilkan modal dialog error bawaan (modal HTML). Dampaknya, state form pengguna terkunci atau hilang.

1. Kontrak Respons Backend: Header Retry-After

Server harus memberikan instruksi durasi cooldown kepada client sesuai RFC 9110. Handler exception di backend harus mengekspos header Retry-After dan merespons format JSON jika permintaan datang dari Inertia.

// bootstrap/app.php (Laravel 11)
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\Request;

->withExceptions(function ($exceptions) {
    $exceptions->render(function (ThrottleRequestsException $e, Request $request) {
        if ($request->header('X-Inertia')) {
            $retryAfter = $e->getHeaders()['Retry-After'] ?? 60;

            return response()->json([
                'message' => 'Terlalu banyak permintaan.',
                'retry_after' => (int) $retryAfter,
            ], 429, [
                'Retry-After' => $retryAfter,
                'Access-Control-Expose-Headers' => 'Retry-After',
            ]);
        }
    });
})

ponytail: Menggunakan render exception langsung tanpa custom middleware throttle. Tambahkan custom rate-limiter bertingkat jika route upload butuh threshold terpisah.

2. Mencegah Error Modal via Event Interceptor

Inertia menyediakan lifecycle hook invalid yang terpicu saat server mengembalikan respons non-Inertia atau error HTTP seperti 429. Panggil event.preventDefault() untuk membungkam modal dialog bawaan, lalu ekstrak waktu jeda.

// resources/js/rate-limit-interceptor.ts
import { router } from '@inertiajs/core';

interface RateLimitEventDetail {
    response: {
        status: number;
        headers: Record<string, string>;
        data?: { retry_after?: number };
    };
}

export function parseRetryAfter(headerValue?: string, fallback = 5): number {
    if (!headerValue) return fallback;
    const seconds = parseInt(headerValue, 10);
    if (!isNaN(seconds)) return seconds;

    // Fallback jika backend mengembalikan format HTTP-date
    const dateDiff = Math.ceil((new Date(headerValue).getTime() - Date.now()) / 1000);
    return dateDiff > 0 ? dateDiff : fallback;
}

3. Implementasi Safe Mutation Replay dengan Jitter

Menjalankan ulang mutasi (POST/PUT/PATCH) secara otomatis membutuhkan kehati-hatian. Naive replay tanpa jeda terdistribusi memicu fenomena thundering herd ketika ratusan tab client mengirim ulang payload secara serentak.

Gunakan rumus Full Jitter: sleep = rand(0, min(cap, base * 2^attempt)).

// resources/js/safe-replay.ts
import { router } from '@inertiajs/core';
import { parseRetryAfter } from './rate-limit-interceptor';

const activeReplays = new Set<string>();

function calculateJitter(cooldownSec: number): number {
    const baseMs = cooldownSec * 1000;
    // Jitter acak antara 100ms hingga 1000ms
    const randomJitter = Math.floor(Math.random() * 900) + 100;
    return baseMs + randomJitter;
}

export function setupRateLimitHandler() {
    router.on('invalid', (event: any) => {
        const response = event.detail.response;

        if (response.status !== 429) return;

        event.preventDefault();

        const headers = response.headers;
        const cooldown = parseRetryAfter(headers['retry-after']);
        const delay = calculateJitter(cooldown);

        const visitConfig = event.detail.visit;
        const requestKey = `${visitConfig.method}:${visitConfig.url.toString()}`;

        // Cegah race condition duplikasi request yang sama di antrean
        if (activeReplays.has(requestKey)) {
            return;
        }

        activeReplays.add(requestKey);

        window.dispatchEvent(
            new CustomEvent('inertia:ratelimit', { 
                detail: { seconds: Math.ceil(delay / 1000) } 
            })
        );

        setTimeout(() => {
            activeReplays.delete(requestKey);

            // Safe replay mutasi dengan preserving state form
            router.visit(visitConfig.url, {
                method: visitConfig.method,
                data: visitConfig.data,
                preserveState: true,
                preserveScroll: true,
                replace: true,
            });
        }, delay);
    });
}

skipped: Sistem antrean multi-attempt persisten. Tambahkan queue IndexedDB jika request harus tetap hidup saat tab browser direfresh.

4. Feedback Non-Intrusif pada UI

Gunakan state global sederhana atau dispatch custom event untuk memberi tahu komponen form bahwa mutasi sedang mengalami cooldown tanpa merusak input teks pengguna.

// resources/js/Components/RateLimitAlert.vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const cooldownSeconds = ref(0);
let timer = null;

function handleRateLimit(e) {
    cooldownSeconds.value = e.detail.seconds;
    clearInterval(timer);
    
    timer = setInterval(() => {
        cooldownSeconds.value--;
        if (cooldownSeconds.value <= 0) {
            clearInterval(timer);
        }
    }, 1000);
}

onMounted(() => window.addEventListener('inertia:ratelimit', handleRateLimit));
onUnmounted(() => {
    window.removeEventListener('inertia:ratelimit', handleRateLimit);
    clearInterval(timer);
});
</script>

<template>
    <div v-if="cooldownSeconds > 0" class="alert alert-warning">
        Server sedang sibuk. Mengulang otomatis dalam {{ cooldownSeconds }} detik...
    </div>
</template>

5. Mitigasi Race Condition & Non-Idempotent Mutations

  • Idempotency Key: Jika endpoint adalah POST (pembuatan invoice/order), tambahkan header unik X-Idempotency-Key yang dibuat sebelum visit pertama. Backend harus memvalidasi key di Redis agar database tidak menerima dua data kembar jika request pertama sebenarnya berhasil dieksekusi sebelum respons 429 timeout keluar.
  • Lock Submit Button: Selalu disable button kirim berbasis status pemrosesan Inertia (form.processing) atau listener custom event rate limit di atas.

6. Runnable Self-Check

Jalankan verifikasi logika parser dan jitter secara langsung di terminal menggunakan Node.js:

// test-ratelimit.js
function parseRetryAfter(val, fallback = 5) {
    if (!val) return fallback;
    const sec = parseInt(val, 10);
    return isNaN(sec) ? fallback : sec;
}

function getJitter(sec) {
    return (sec * 1000) + Math.floor(Math.random() * 500);
}

// Assertions
const check1 = parseRetryAfter('12') === 12;
const check2 = parseRetryAfter(undefined) === 5;
const delay = getJitter(2);
const check3 = delay >= 2000 && delay <= 2500;

if (!check1 || !check2 || !check3) {
    throw new Error('Self-check verification failed');
}
console.log('OK: Rate limit logic verified.');