Anatomi Masalah: Cascading Failure pada Monolith Inertia

Saat Inertia.js controller memanggil upstream HTTP API secara synchronous pada lifecycle request, controller terikat langsung pada latensi dependensi eksternal. Jika upstream mengalami degradasi performa atau downtime total, eksekusi PHP thread akan tertahan hingga mencapai batasan network timeout default.

Kondisi ini memicu worker pool exhaustion. Pada arsitektur PHP-FPM atau Laravel Octane, jumlah worker process terbatas (misal 50–200 worker). Ketika puluhan request masuk secara bersamaan dan masing-masing menunggu upstream timeout selama 10–30 detik, seluruh worker pool habis terpakai hanya untuk menunggu I/O. Akibatnya, web server (Nginx/Traefik) memutus koneksi dengan status HTTP 504 Gateway Timeout. Halaman statis atau rute lain yang tidak terhubung dengan upstream API ikut tumbang (cascading failure).

Implementasi Circuit Breaker Backend dengan Redis

Pola Circuit Breaker mencegah aplikasi memanggil remote service yang diketahui sedang bermasalah. Pola ini beroperasi dalam tiga state:

  • CLOSED: Permintaan dialirkan normal. Kegagalan dihitung secara berurutan.
  • OPEN: Ambang batas kegagalan terlampaui. Panggilan dicegat langsung tanpa menyentuh upstream (fail-fast), mengembalikan fallback.
  • HALF-OPEN: Masa cooldown selesai. Sejumlah kecil request diizinkan lewat untuk menguji pemulihan upstream.

Implementasi minimal Circuit Breaker memanfaatkan atomic operations pada Redis melalui interface Cache Laravel:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Throwable;
use Closure;

class CircuitBreaker
{
    public const STATE_CLOSED = 'CLOSED';
    public const STATE_OPEN = 'OPEN';
    public const STATE_HALF_OPEN = 'HALF_OPEN';

    public function __construct(
        private string $service,
        private int $threshold = 3,
        private int $recoveryTimeout = 30
    ) {}

    public function execute(Closure $action, Closure $fallback): mixed
    {
        $state = $this->getState();

        if ($state === self::STATE_OPEN) {
            return $fallback('UPSTREAM_OPEN');
        }

        try {
            $result = $action();
            $this->handleSuccess($state);
            return $result;
        } catch (Throwable $e) {
            $this->handleFailure($state);
            return $fallback($e->getMessage());
        }
    }

    public function getState(): string
    {
        if (Cache::has("cb:{$this->service}:open")) {
            return self::STATE_OPEN;
        }

        if (Cache::has("cb:{$this->service}:half_open")) {
            return self::STATE_HALF_OPEN;
        }

        return self::STATE_CLOSED;
    }

    private function handleSuccess(string $state): void
    {
        if ($state === self::STATE_HALF_OPEN) {
            Cache::forget("cb:{$this->service}:failures");
            Cache::forget("cb:{$this->service}:half_open");
        }
    }

    private function handleFailure(string $state): void
    {
        // ponytail: distributed locks omitted; atomic increment provides sufficient accuracy for failure counts
        $failures = (int) Cache::increment("cb:{$this->service}:failures");

        if ($failures >= $this->threshold || $state === self::STATE_HALF_OPEN) {
            Cache::put("cb:{$this->service}:open", true, $this->recoveryTimeout);
            Cache::forget("cb:{$this->service}:half_open");
            // Set flag half-open untuk dievaluasi setelah status OPEN kedaluwarsa
            Cache::put("cb:{$this->service}:half_open", true, $this->recoveryTimeout + 15);
        }
    }
}

Perancangan Kontrak Fallback Props

Saat sirkuit terbuka atau request gagal, controller tidak boleh melempar unhandled exception. Controller wajib mengirim struktur props yang konsisten sehingga engine rendering frontend (Vue, React, atau Svelte) tidak mengalami runtime null-pointer error.

Gunakan bentuk kontrak terstruktur seperti contoh controller berikut:

<?php

namespace App\Http\Controllers;

use App\Services\CircuitBreaker;
use Illuminate\Support\Facades\Http;
use Inertia\Inertia;
use Inertia\Response;

class PaymentGatewayReportController extends Controller
{
    public function show(CircuitBreaker $cb): Response
    {
        $breaker = new CircuitBreaker(service: 'payment-upstream', threshold: 3, recoveryTimeout: 20);

        $metrics = $breaker->execute(
            action: function () {
                return Http::timeout(2)
                    ->baseUrl('https://api.external-payment.test')
                    ->get('/v1/settlements')
                    ->throw()
                    ->json('data');
            },
            fallback: function (string $reason) {
                return null;
            }
        );

        return Inertia::render('Reports/Payment', [
            'settlements' => [
                'data' => $metrics,
                'isDegraded' => is_null($metrics),
                'circuitState' => $breaker->getState(),
            ],
        ]);
    }
}

Client-Side Recovery Menggunakan Partial Reload

Ketika status backend berada dalam kondisi isDegraded: true, frontend dapat menyediakan aksi retry manual tanpa memuat ulang seluruh halaman (full reload). Fitur only pada Inertia Router mengisolasi request agar backend hanya mengevaluasi prop yang bermasalah.

Komponen Vue 3 berikut menangani status terdegradasi dan mengeksekusi partial reload:

<script setup>
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';

const props = defineProps({
  settlements: {
    type: Object,
    required: true,
  }
});

const isRetrying = ref(false);

const retryFetch = () => {
  isRetrying.value = true;
  router.reload({
    only: ['settlements'],
    onFinish: () => {
      isRetrying.value = false;
    }
  });
};
</script>

<template>
  <div class="p-6 space-y-4">
    <h1 class="text-xl font-bold">Laporan Settlement</h1>

    <div v-if="settlements.isDegraded" class="border border-amber-400 bg-amber-50 p-4 rounded text-amber-900">
      <p>Data upstream sementara tidak tersedia (Status: {{ settlements.circuitState }}).</p>
      <button 
        @click="retryFetch" 
        :disabled="isRetrying"
        class="mt-2 px-3 py-1 bg-amber-600 text-white rounded text-sm disabled:opacity-50">
        {{ isRetrying ? 'Memeriksa...' : 'Coba Lagi' }}
      </button>
    </div>

    <div v-else class="grid gap-2">
      <pre class="bg-gray-100 p-4 rounded">{{ settlements.data }}</pre>
    </div>
  </div>
</template>

Verifikasi Runnable: Transisi State & Fallback Props

Gunakan pengujian integration/unit berikut untuk memvalidasi bahwa Circuit Breaker memotong koneksi saat ambang batas kegagalan tercapai dan mengembalikan payload fallback:

<?php

namespace Tests\Unit;

use Tests\TestCase;
use App\Services\CircuitBreaker;
use Illuminate\Support\Facades\Cache;
use RuntimeException;

class CircuitBreakerTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Cache::flush();
    }

    public function test_trips_to_open_and_returns_fallback_after_threshold(): void
    {
        $breaker = new CircuitBreaker(service: 'order-api', threshold: 2, recoveryTimeout: 10);
        $fallbackInvokedCount = 0;

        $run = fn () => $breaker->execute(
            action: fn () => throw new RuntimeException('Connection timeout'),
            fallback: function ($reason) use (&$fallbackInvokedCount) {
                $fallbackInvokedCount++;
                return 'FALLBACK_VALUE';
            }
        );

        // Eksekusi 1: Gagal pertama kali (State masih CLOSED, failure = 1)
        $res1 = $run();
        $this->assertEquals('FALLBACK_VALUE', $res1);
        $this->assertEquals(CircuitBreaker::STATE_CLOSED, $breaker->getState());

        // Eksekusi 2: Gagal kedua kali (Mencapai threshold = 2, State menjadi OPEN)
        $res2 = $run();
        $this->assertEquals('FALLBACK_VALUE', $res2);
        $this->assertEquals(CircuitBreaker::STATE_OPEN, $breaker->getState());

        // Eksekusi 3: Fail-fast langsung tanpa mengeksekusi action
        $executed = false;
        $res3 = $breaker->execute(
            action: function () use (&$executed) {
                $executed = true;
                return 'SUCCESS';
            },
            fallback: fn () => 'FAIL_FAST'
        );

        $this->assertFalse($executed);
        $this->assertEquals('FAIL_FAST', $res3);
        $this->assertEquals(2, $fallbackInvokedCount);
    }
}