← semua materi
05:00 Arsitektur Β· 04 Nov 2026 Β· 05:02

πŸ“š Topik: Circuit Breaker, Bulkhead, Timeout Budget β€” Pola Ketahanan dan Kapan Masing-Masing Justru Merusak

Difficulty: 2/3 Β· Kategori: Resilience


🎯 Tujuan Belajar

Setelah sesi ini, keputusan arsitektur berikut jadi lebih tajam:

  1. Bisa memilih parameter circuit breaker berbasis matematika beban, bukan copy-paste default.
  2. Tahu di layer mana resiliency dipasang (client library vs sidecar vs gateway) dan konsekuensi tiap pilihan.
  3. Bisa membedakan pola yang melindungi dari pola yang memperparah β€” khususnya retry + timeout yang saling menumpuk.
  4. Tahu kapan tidak memasang circuit breaker (dan itu sering kali jawaban yang benar).

🧠 Penjelasan Inti

Tiga pola ini menyelesaikan satu masalah yang sama dari tiga sudut: kegagalan tidak boleh menjalar melampaui batas blast radius-nya.

Circuit breaker β€” menghentikan pengiriman request ke dependency yang sedang gagal. Bukan mempercepat dependency pulih; tujuannya memindahkan biaya kegagalan dari waktu tunggu (latency) ke kegagalan cepat (fail fast). Nilai utamanya bukan "menyelamatkan downstream", tapi menyelamatkan caller: thread pool, connection pool, dan kuota memori caller tidak habis menunggu timeout.

Bulkhead β€” membatasi jumlah konkurensi (atau resource) per dependency/per kelas trafik. Berbeda dari circuit breaker yang reaktif terhadap kegagalan, bulkhead bersifat proaktif dan statis: ia membatasi kerusakan bahkan ketika dependency tidak "gagal" menurut metrik, tapi cuma lambat.

Timeout budget β€” anggaran waktu total sebuah request end-to-end, dipecah ke tiap hop. Ini bukan pola library, ini disiplin desain. Tanpa deadline propagation, timeout di tiap layer hanya menumpuk dan menghasilkan ekor latensi yang jauh lebih besar dari yang diharapkan siapa pun.

DIKETAHUI: ketiganya sudah mapan sejak laporan Nygard Release It! (2007). Tidak ada perubahan konsep fundamental. DIINFERENSI: implementasi modern bergeser ke deklaratif (pipeline, middleware) dan ke layer infrastruktur (service mesh), bukan lagi wrapper manual per call site.


βš™οΈ Cara Kerja Internal

Circuit Breaker β€” state machine

                 failures >= threshold
      CLOSED ───────────────────────────► OPEN
        β–²                                  β”‚
        β”‚                                  β”‚ waitDuration
        β”‚  N sukses berturut               β–Ό
        └──────────────────────────── HALF_OPEN
                                           β”‚
                         1 gagal            β”‚
        OPEN β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Tiga detail implementasi yang menentukan perilaku nyata:

1. Window dan threshold bukan satu angka. Sebagian besar library memakai sliding window + minimum throughput. Resilience4j default: slidingWindowSize=100, minimumNumberOfCalls=100, failureRateThreshold=50. Artinya breaker tidak akan pernah membuka sebelum 100 call terkumpul. Pada service low-traffic (10 req/menit), breaker never trips β€” dan ini bug konfigurasi paling umum di produksi.

2. Slow call β‰  failure call. Resilience4j punya slowCallRateThreshold (default 100 = praktis nonaktif) dan slowCallDurationThreshold (default 60000ms). Tanpa mengaktifkan ini, breaker tetap CLOSED saat semua request butuh 30 detik β€” padahal itu justru skenario yang paling merusak caller (thread habis, bukan error naik).

3. Half-open itu gerbang, bukan uji bebas. permittedNumberOfCallsInHalfOpenState (default 10) menentukan berapa probe yang diizinkan. Di Polly v8 ada BreakDurationGenerator untuk backoff eksponensial pada durasi break β€” lebih baik daripada break duration tetap yang bikin retry storm periodik.

Detail kritis yang sering terlewat: di Resilience4j, automaticTransitionFromOpenToHalfOpenEnabled default false — transisi OPEN→HALF_OPEN hanya terjadi kalau ada call yang datang. Kalau trafik berhenti total, breaker tetap OPEN. Kalau diaktifkan, library membuat thread monitor global. Konsekuensinya: perilaku breaker bergantung pada pola trafik, bukan hanya waktu.

Bulkhead β€” dua varian yang sangat berbeda

Aspek Semaphore bulkhead Thread-pool bulkhead
Mekanisme Counter konkurensi + optional queue Pool thread terpisah per dependency
Biaya ~nol (atomic counter) Thread/stack per pool; context switch
Timeout Tidak bisa memutus call yang sudah jalan Bisa, karena caller tidak memblokir
Backpressure Caller ikut memblokir saat penuh Caller menerima rejection langsung
Bahaya Blokir thread caller β†’ cascading di caller Deadlock antar-pool, context propagation rusak

Semaphore bulkhead memblokir thread pemanggil ketika kuota habis. Di runtime async (Node, Go, .NET async, Java Loom) ini berbahaya: memblokir event loop / carrier thread menghabiskan seluruh kapasitas proses. Thread-pool bulkhead lebih aman untuk isolasi tapi memindahkan masalah ke context propagation (trace ID, MDC, transaction, ThreadLocal hilang).

Timeout budget β€” aritmetika yang sering salah

Client budget: 2000ms
 β”œβ”€ gateway overhead       100ms
 β”œβ”€ service A processing   300ms
 β”œβ”€ service B (downstream) 1000ms ← timeout diset 1000ms di sini
 β”‚    └─ service C         800ms  ← timeout diset 800ms di sini
 └─ retry 1x di A?        +1400ms ← BUDGET JEBOL

Aturan: timeout_downstream < timeout_upstream βˆ’ overhead. Dan deadline harus dipropagasikan (gRPC grpc-timeout header, context.Deadline di Go, CancellationToken di .NET, OpenTelemetry baggage), bukan di-set ulang di tiap hop.

Tanpa propagasi, tiap service punya timeout independen dan total waktu terburuk = jumlah semua timeout, bukan maksimum.


πŸ—οΈ Pseudocode: urutan yang benar

Urutan dekorator menentukan semantik. Urutan yang salah menghasilkan breaker yang menghitung retry sebagai kegagalan independen.

# BENAR: retry di LUAR breaker
call = Retry(max=2, budget=global)(
         CircuitBreaker(
           Timeout(ms=remaining_deadline)(
             Bulkhead(permits=20)( http_call ))))

# Timeout harus menghitung deadline SISA dari request context,
# bukan durasi tetap dari awal call.

Konsekuensi urutan: - Retry di luar breaker: kegagalan yang sudah di-retry masuk hitungan breaker sekali per attempt β€” breaker lebih cepat trip. Ini biasanya diinginkan. - Breaker di luar retry: breaker melihat satu hasil final per logical call β€” breaker lebih lambat trip, retry bisa menembus breaker yang setengah terbuka. - Timeout di dalam breaker: call yang timeout dihitung sebagai kegagalan breaker. Benar. - Timeout di luar breaker: breaker tidak pernah melihat timeout sebagai kegagalan. Salah β€” inilah bug yang bikin breaker tidak pernah trip saat dependency lambat.

Polly v8 menyelesaikan ini dengan pipeline eksplisit (.AddRetry().AddCircuitBreaker().AddTimeout()), dieksekusi dari luar ke dalam. Resilience4j mendelegasikan urutan ke annotation order β€” sumber bug klasik di Spring.


βš–οΈ Trade-off & Kapan JANGAN Dipakai

Ini bagian inti.

Circuit breaker β€” jangan dipakai kalau:

Kondisi Alasan
Traffic rendah (< ~1 req/detik per instance) minimumNumberOfCalls tidak pernah tercapai; breaker jadi dead code yang memberi rasa aman palsu
Dependency punya >2 replika dengan load balancing client-side Breaker per-endpoint vs per-host berbeda secara fundamental. Breaker per-service akan trip karena satu host buruk; breaker per-host butuh library yang mendukungnya (gRPC punya, banyak HTTP client tidak)
Fallback tidak ada dan kegagalan = kegagalan Breaker hanya mengubah error 500 menjadi error 503 lebih cepat. Kalau tidak ada jalur degradasi, nilainya cuma penghematan resource β€” sering tidak sepadan kompleksitas
Operasi idempotent + retry sudah cukup Breaker menambah state yang harus di-monitor, di-tune, dan di-debug saat insiden. Kalau dependency benar-benar down, retry dengan backoff + timeout sudah cukup

Bulkhead β€” jangan dipakai kalau:

Timeout β€” jangan dipakai kalau:

Interaksi yang mematikan

N caller Γ— R retry Γ— M hop = N Γ— R^M beban saat degradasi

3 retry, 3 hop = 27x. Google SRE merekomendasikan retry budget ~10% dari total request [2]: kalau retry ratio melewati itu, hentikan retry sepenuhnya. Retry tanpa budget adalah pengali DDoS yang Anda bangun sendiri.

Timeout + retry tanpa deadline propagation adalah kombinasi paling umum penyebab cascading failure di produksi.


🏭 Contoh Produksi & Failure Mode

1. Breaker trip karena deploy, bukan karena dependency down. Rolling deploy 10 instance β†’ satu instance baru belum warm (JIT, cache kosong) β†’ respons 3x lebih lambat β†’ minimumNumberOfCalls kecil β†’ breaker di instance lain trip β†’ trafik menumpuk ke sisa instance β†’ trip berantai. Mitigasi: warm-up/readiness gate, dan slowCallRateThreshold di-set supaya "lambat karena cold start" tidak otomatis = gagal.

2. Half-open stampede. Breaker OPEN untuk dependency yang dipakai 200 service. waitDurationInOpenState habis bersamaan β†’ 200 probe serentak β†’ dependency yang baru pulih langsung tumbang lagi β†’ breaker buka lagi. Mitigasi: jitter pada wait duration, permittedNumberOfCallsInHalfOpenState kecil (1–3), dan probe bertahap.

3. Thread pool bulkhead deadlock. Service A punya pool 20 untuk B dan pool 20 untuk C. B memanggil C lewat pool yang sama. Semua thread A→B menunggu C, semua thread A→C menunggu B. Mitigasi: bulkhead per dependency, bukan per call path, plus analisis dependency graph. Isolasi tanpa pemetaan graf = deadlock.

4. Timeout yang tidak membatalkan. Go: context.WithTimeout sudah benar (cancel propagates). Node: AbortController harus dipasang manual ke fetch β€” mudah lupa. Java: CompletableFuture.orTimeout tidak menghentikan task di executor. Akibat: DB masih menjalankan query 30 detik setelah client pergi, koneksi pool tetap terpakai.

5. Breaker menyembunyikan degradasi bertahap. Latensi naik dari 50ms β†’ 400ms perlahan. Tidak ada error, breaker tidak pernah trip, semua dashboard hijau. Ini yang tidak ditangkap circuit breaker β€” butuh SLO alerting berbasis latency percentile di sisi caller. Breaker bukan pengganti observability.

6. Observability breaker yang buruk. State breaker adalah state internal library. Tanpa ekspos eksplisit (Resilience4j: resilience4j.circuitbreaker.state metric + events endpoint; Polly v8: CircuitBreakerStateProvider), saat insiden Anda tidak tahu apakah breaker trip atau dependency lambat. Breaker tanpa metrik = black box yang harus di-restart untuk di-debug.


⚠️ Miskonsepsi Umum


βœ… Cek Pemahaman

  1. Service Anda menerima 8 req/menit per instance. Anda pasang Resilience4j dengan failureRateThreshold=50, minimumNumberOfCalls=100. Dependency down total. Berapa lama sampai breaker trip? Apa yang seharusnya Anda ubah β€” dan apa risiko perubahan itu?

  2. Anda punya rantai A → B → C. Timeout A→B = 5s, B→C = 5s. P99 latency C naik ke 4s. Jelaskan mengapa P99 latency A bisa mencapai ~9s, dan bagaimana deadline propagation mengubahnya.

  3. Mengapa thread-pool bulkhead bisa deadlock sementara semaphore bulkhead tidak? Dalam kondisi apa semaphore bulkhead justru lebih berbahaya β€” khususnya di runtime async?


πŸ”‘ Poin Kunci


➑️ Eksplorasi Lanjut


Status keyakinan: Konsep fundamental β€” tinggi (mapan, tidak berubah). Nilai default dan fitur library β€” terverifikasi per rilis terbaru: Resilience4j 2.4.0 (Maven Central, Mar 2026), Polly v8 dengan ResiliencePipeline + BreakDurationGenerator. Retry budget 10% β€” bersumber dari Google SRE Book, bukan benchmark independen; angka aktual bergantung topologi.

Sumber: [1] Resilience4j. "CircuitBreaker β€” configuration reference" β€” resilience4j.readme.io/docs/circuitbreaker [2] Google SRE. "Handling Overload β€” Load Balancing with Client-Side Throttling" β€” sre.google/sre-book/handling-overload/ [3] Polly. "Migration guide v7 β†’ v8" β€” pollydocs.org/migration-v8.html [4] Microsoft Learn. "Introduction to resilient app development" β€” learn.microsoft.com/dotnet/core/resilience/ [5] Google SRE. "Addressing Cascading Failures" β€” sre.google/sre-book/addressing-cascading-failures/