π 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:
- Bisa memilih parameter circuit breaker berbasis matematika beban, bukan copy-paste default.
- Tahu di layer mana resiliency dipasang (client library vs sidecar vs gateway) dan konsekuensi tiap pilihan.
- Bisa membedakan pola yang melindungi dari pola yang memperparah β khususnya retry + timeout yang saling menumpuk.
- 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:
- Kapasitas total Anda tipis. Bulkhead membagi resource tetap. Kalau service hanya punya 50 koneksi DB dan butuh 50 untuk trafik normal, membagi jadi 20/20/10 membuat tiap pool kehabisan saat burst. Bulkhead butuh headroom; tanpa headroom ia hanya membagi kelangkaan.
- Dependency-nya sedikit dan homogen. Bulkhead bernilai saat ada banyak dependency heterogen (atau tenant heterogen). Satu dependency, satu kelas trafik β bulkhead = duplikasi connection pool limit.
Timeout β jangan dipakai kalau:
- Operasi memang panjang dan asinkron (batch export, ML inference 10 menit). Timeout di jalur sinkron akan membunuhnya. Ubah model: submit β poll/webhook, bukan memperpanjang timeout.
- Timeout diset tanpa cancel. Timeout yang tidak membatalkan pekerjaan downstream hanya memindahkan beban: caller sudah menyerah, downstream masih menghitung. Ini sumber retry amplification β 3x beban untuk pekerjaan yang hasilnya sudah dibuang.
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
- "Circuit breaker melindungi downstream." Utamanya melindungi caller dari resource exhaustion. Downstream terlindungi sebagai efek samping β dan hanya kalau breaker dipasang di banyak caller sekaligus.
- "Default config cukup." Default Resilience4j (
minimumNumberOfCalls=100) praktis tidak pernah trip di service low-traffic. Default ada untuk library demo, bukan produksi. - "Bulkhead = rate limiter." Rate limiter membatasi laju (req/detik), bulkhead membatasi konkurensi (req in-flight). Service dengan 1000 req/detik dan latency 10ms butuh konkurensi ~10; rate limit 1000 tidak melindungi apa pun dari lonjakan latency.
- "Retry bikin sistem lebih reliable." Retry meningkatkan success rate pada kegagalan transien, dan meningkatkan beban pada kegagalan persisten. Net effect bergantung pada apakah kegagalan itu independen atau berkorelasi. Kegagalan berkorelasi (overload, dependency down) β retry memperburuk.
- "Timeout 30s itu aman karena dependency biasanya 50ms." Timeout adalah batas atas resource yang Anda izinkan terpakai. Timeout 30s = satu call boleh menahan satu thread selama 30s. Dengan 200 thread, 7 call macet sudah menghabiskan kapasitas.
- "Circuit breaker butuh fallback." Tidak wajib. Fail fast tanpa fallback tetap valid dan sering lebih baik daripada fallback yang mengembalikan data basi tanpa penanda.
β Cek Pemahaman
-
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? -
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.
-
Mengapa thread-pool bulkhead bisa deadlock sementara semaphore bulkhead tidak? Dalam kondisi apa semaphore bulkhead justru lebih berbahaya β khususnya di runtime async?
π Poin Kunci
- Circuit breaker memindahkan biaya kegagalan dari latency ke fail-fast. Nilainya di caller, bukan downstream.
- Parameter breaker harus diturunkan dari volume trafik dan SLO latency, bukan default library.
minimumNumberOfCallsadalah parameter yang paling sering salah dan paling menentukan apakah breaker berfungsi sama sekali.- Slow call adalah kegagalan. Tanpa
slowCallRateThreshold, breaker buta terhadap mode kegagalan yang paling merusak. - Bulkhead butuh headroom. Tanpa headroom, ia membagi kelangkaan, bukan menyerap lonjakan.
- Timeout harus propagasi deadline, bukan nilai tetap per hop. Timeout tanpa cancel = retry amplification.
- Retry tanpa budget adalah pengali beban. 10% retry budget adalah titik awal yang wajar.
- Breaker tidak menggantikan SLO alerting, observability, atau load shedding. Ia satu lapis, bukan solusi.
- Urutan komposisi (retry/breaker/timeout/bulkhead) mengubah semantik. Tidak ada urutan "benar" universal β ada urutan yang cocok dengan model kegagalan Anda.
β‘οΈ Eksplorasi Lanjut
- Adaptive concurrency limits (Netflix concurrency-limits, Gradient2, Vegas) β pengganti bulkhead statis yang menyesuaikan permit berdasarkan latensi terukur. Ini arah yang lebih benar untuk sistem dengan beban fluktuatif.
- Load shedding vs circuit breaking β shedding memilih request mana yang dibuang (prioritas, tenant, endpoint); breaking memilih dependency mana yang dihindari. Keduanya sering tertukar.
- Hedging (Polly v8, gRPC) β kirim request duplikat setelah p99, ambil yang pertama kembali. Efektif untuk kegagalan ekor, mahal untuk dependency dengan biaya per-call.
- Service mesh resilience (Istio/Envoy outlier detection, Linkerd) β memindahkan pola ini keluar dari kode. Trade-off: konsistensi lintas bahasa vs kehilangan konteks aplikasi (breaker tidak tahu mana error bisnis vs error infrastruktur).
- PACELC & timeout sebagai keputusan konsistensi β timeout adalah cara Anda memilih "unavailable" di atas "lambat". Hubungkan dengan model konsistensi di sesi distributed systems berikutnya.
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/