Circuit Breaker #
Bayangkan sistem e-commerce yang bergantung pada payment gateway eksternal. Suatu hari, payment gateway mengalami gangguan dan setiap request ke sana butuh 30 detik sebelum timeout. Tanpa mekanisme perlindungan, semua thread yang menangani checkout akan tersandera menunggu timeout — 30 detik × ribuan request bersamaan — dan seluruh aplikasi menjadi tidak responsif meski hanya satu dependency yang bermasalah.
Circuit breaker adalah pattern yang terinspirasi dari dunia kelistrikan: sebuah pengaman yang otomatis memutus sirkuit ketika mendeteksi beban berlebih, mencegah kerusakan yang lebih parah ke sistem yang lebih luas. Dalam software, circuit breaker memantau kegagalan terhadap suatu dependency — database, API eksternal, atau service internal — dan ketika kegagalan melampaui threshold, ia “membuka” sirkuit: request langsung gagal dengan cepat tanpa menunggu timeout, memberikan waktu bagi dependency untuk pulih.
Pattern ini adalah salah satu fondasi dari sistem yang resilient — sistem yang bisa menghadapi kegagalan parsial tanpa kegagalan total.
Tiga State Circuit Breaker #
Circuit breaker beroperasi dalam tiga state yang berpindah secara otomatis berdasarkan kondisi dependency.
stateDiagram-v2
[*] --> Closed
Closed --> Open : Failure threshold tercapai\n(misalnya: 5 gagal dalam 60 detik)
Open --> HalfOpen : Recovery timeout berlalu\n(misalnya: setelah 30 detik)
HalfOpen --> Closed : Probe request berhasil\n(misalnya: 2 success berturut-turut)
HalfOpen --> Open : Probe request gagal\n(langsung kembali Open)
state Closed {
[*]: Request diizinkan\nKegagalan dihitung
}
state Open {
[*]: Request langsung ditolak\nTidak ada call ke dependency
}
state HalfOpen {
[*]: Beberapa request diizinkan\nsebagai probe
}Detail setiap state:
CLOSED (Normal):
→ Semua request diteruskan ke dependency
→ Setiap kegagalan dicatat
→ Jika failure rate melampaui threshold → pindah ke OPEN
→ Jika dalam window waktu kegagalan tidak cukup → counter direset
OPEN (Sirkuit Terbuka — Melindungi):
→ Semua request langsung gagal (fast fail)
→ Tidak ada request yang dikirim ke dependency
→ Setelah recovery_timeout berlalu → pindah ke HALF-OPEN
→ Manfaat: latency rendah (tidak ada timeout), dependency punya waktu pulih
HALF-OPEN (Probe):
→ Beberapa request probe dikirim ke dependency
→ Jika berhasil → pindah ke CLOSED (sistem normal)
→ Jika gagal → kembali ke OPEN (dependency belum pulih)
→ Mencegah sistem membanjiri dependency yang baru mulai pulih
Implementasi dari Scratch #
Memahami implementasi dari awal membantu engineer mengkonfigurasi dan men-debug circuit breaker dengan lebih baik.
import threading
import time
from enum import Enum
from dataclasses import dataclass, field
from collections import deque
from typing import Callable, Any
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
@dataclass
class CircuitBreakerConfig:
# Berapa kegagalan dalam window sebelum OPEN
failure_threshold: int = 5
# Window waktu untuk menghitung kegagalan (detik)
failure_window: float = 60.0
# Berapa lama di state OPEN sebelum coba HALF-OPEN (detik)
recovery_timeout: float = 30.0
# Berapa success di HALF-OPEN sebelum kembali CLOSED
success_threshold: int = 2
# Berapa request yang diizinkan di HALF-OPEN per window
half_open_max_calls: int = 3
# Exception apa yang dianggap kegagalan
expected_exceptions: tuple = (Exception,)
class CircuitBreaker:
"""
Circuit breaker dengan sliding window untuk failure tracking.
Thread-safe untuk aplikasi concurrent.
"""
def __init__(self, name: str, config: CircuitBreakerConfig = None):
self.name = name
self.config = config or CircuitBreakerConfig()
self._state = CircuitState.CLOSED
self._lock = threading.RLock()
# Sliding window: simpan timestamp setiap kegagalan
self._failure_timestamps: deque = deque()
self._success_count = 0
self._half_open_calls = 0
self._opened_at: float = None
# Callbacks untuk observability
self.on_state_change: Callable = None
@property
def state(self) -> CircuitState:
return self._state
def call(self, func: Callable, *args, **kwargs) -> Any:
"""
Eksekusi fungsi melalui circuit breaker.
Raise CircuitOpenError jika sirkuit terbuka.
"""
with self._lock:
self._evaluate_state()
if self._state == CircuitState.OPEN:
raise CircuitOpenError(
f"Circuit '{self.name}' is OPEN. "
f"Dependency unavailable, try again in "
f"{self._seconds_until_half_open():.0f}s"
)
if self._state == CircuitState.HALF_OPEN:
if self._half_open_calls >= self.config.half_open_max_calls:
raise CircuitOpenError(
f"Circuit '{self.name}' is HALF-OPEN, "
f"max probe calls reached"
)
self._half_open_calls += 1
# Eksekusi di luar lock agar tidak blocking thread lain
try:
result = func(*args, **kwargs)
self._record_success()
return result
except self.config.expected_exceptions as e:
self._record_failure()
raise
def _evaluate_state(self):
"""Cek apakah state perlu diubah berdasarkan kondisi saat ini."""
if self._state == CircuitState.OPEN:
if self._should_attempt_recovery():
self._transition_to(CircuitState.HALF_OPEN)
def _record_success(self):
with self._lock:
if self._state == CircuitState.HALF_OPEN:
self._success_count += 1
if self._success_count >= self.config.success_threshold:
self._transition_to(CircuitState.CLOSED)
def _record_failure(self):
with self._lock:
now = time.time()
self._failure_timestamps.append(now)
# Hapus failure yang sudah di luar window
cutoff = now - self.config.failure_window
while self._failure_timestamps and \
self._failure_timestamps[0] < cutoff:
self._failure_timestamps.popleft()
if self._state == CircuitState.HALF_OPEN:
# Langsung kembali ke OPEN jika probe gagal
self._transition_to(CircuitState.OPEN)
elif (self._state == CircuitState.CLOSED and
len(self._failure_timestamps) >= self.config.failure_threshold):
self._transition_to(CircuitState.OPEN)
def _transition_to(self, new_state: CircuitState):
old_state = self._state
self._state = new_state
if new_state == CircuitState.OPEN:
self._opened_at = time.time()
self._success_count = 0
self._half_open_calls = 0
elif new_state == CircuitState.CLOSED:
self._failure_timestamps.clear()
self._success_count = 0
self._half_open_calls = 0
elif new_state == CircuitState.HALF_OPEN:
self._success_count = 0
self._half_open_calls = 0
if self.on_state_change and old_state != new_state:
self.on_state_change(self.name, old_state, new_state)
def _should_attempt_recovery(self) -> bool:
if self._opened_at is None:
return False
return (time.time() - self._opened_at) >= self.config.recovery_timeout
def _seconds_until_half_open(self) -> float:
if self._opened_at is None:
return 0
elapsed = time.time() - self._opened_at
return max(0, self.config.recovery_timeout - elapsed)
@property
def failure_count(self) -> int:
"""Jumlah kegagalan dalam window saat ini."""
now = time.time()
cutoff = now - self.config.failure_window
return sum(1 for ts in self._failure_timestamps if ts >= cutoff)
class CircuitOpenError(Exception):
"""Raised ketika circuit breaker dalam state OPEN."""
pass
Penggunaan di Aplikasi Nyata #
import httpx
import logging
logger = logging.getLogger(__name__)
# Setup circuit breaker dengan callback untuk monitoring
payment_cb = CircuitBreaker(
name="payment-gateway",
config=CircuitBreakerConfig(
failure_threshold=5,
failure_window=60.0,
recovery_timeout=30.0,
success_threshold=2,
expected_exceptions=(httpx.HTTPError, httpx.TimeoutException),
)
)
def on_circuit_state_change(name, old_state, new_state):
logger.warning(
f"Circuit breaker state change",
extra={
'circuit': name,
'from': old_state.value,
'to': new_state.value,
}
)
# Kirim metric ke monitoring
metrics.increment(f'circuit_breaker.state_change',
tags={'circuit': name, 'state': new_state.value})
payment_cb.on_state_change = on_circuit_state_change
# Fungsi yang dilindungi circuit breaker
async def charge_payment(order_id: str, amount: float) -> dict:
def _call():
response = httpx.post(
"https://payment-gateway.com/charge",
json={"order_id": order_id, "amount": amount},
timeout=10.0
)
response.raise_for_status()
return response.json()
try:
return payment_cb.call(_call)
except CircuitOpenError:
# Circuit terbuka — gunakan fallback
logger.warning(f"Payment circuit open for order {order_id}")
return await fallback_payment_handler(order_id, amount)
except httpx.HTTPError as e:
# Request gagal (circuit masih closed/half-open, kegagalan dicatat)
logger.error(f"Payment request failed for order {order_id}: {e}")
raise PaymentError(f"Payment processing failed: {e}")
async def fallback_payment_handler(order_id: str, amount: float) -> dict:
"""
Fallback ketika payment gateway tidak tersedia.
Opsi:
1. Queue untuk diproses nanti (async)
2. Coba payment provider alternatif
3. Return error yang informatif ke user
"""
# Opsi 1: Queue ke background job
await queue_pending_payment(order_id, amount)
return {
"status": "queued",
"message": "Pembayaran akan diproses dalam beberapa menit",
"order_id": order_id
}
Library Circuit Breaker yang Tersedia #
Untuk production, sebaiknya gunakan library yang sudah teruji daripada implementasi sendiri.
# Python: pybreaker
from pybreaker import CircuitBreaker, CircuitBreakerError
payment_breaker = CircuitBreaker(
fail_max=5,
reset_timeout=30,
exclude=[ValueError] # exception ini tidak dihitung sebagai failure
)
@payment_breaker
def call_payment_api(order_id: str, amount: float):
response = httpx.post("https://payment-gateway.com/charge", ...)
return response.json()
try:
result = call_payment_api(order_id, amount)
except CircuitBreakerError:
# Circuit terbuka
handle_payment_unavailable(order_id)
// Go: sony/gobreaker
package main
import (
"github.com/sony/gobreaker"
"time"
)
var paymentBreaker = gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "payment-gateway",
MaxRequests: 2, // max request di HALF-OPEN
Interval: 60 * time.Second, // window untuk reset counter
Timeout: 30 * time.Second, // recovery timeout (OPEN → HALF-OPEN)
ReadyToTrip: func(counts gobreaker.Counts) bool {
// Custom logic: buka circuit jika failure rate > 60% dengan min 5 request
if counts.Requests < 5 {
return false
}
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return failureRatio >= 0.6
},
OnStateChange: func(name string, from, to gobreaker.State) {
log.Printf("Circuit %s: %s → %s", name, from, to)
},
})
func chargePayment(orderID string, amount float64) (PaymentResult, error) {
result, err := paymentBreaker.Execute(func() (interface{}, error) {
return callPaymentAPI(orderID, amount)
})
if err != nil {
if err == gobreaker.ErrOpenState {
return PaymentResult{}, ErrPaymentUnavailable
}
return PaymentResult{}, err
}
return result.(PaymentResult), nil
}
// Node.js: opossum
const CircuitBreaker = require('opossum')
const options = {
timeout: 10000, // timeout per request (ms)
errorThresholdPercentage: 50, // buka jika error rate > 50%
resetTimeout: 30000, // coba lagi setelah 30 detik
volumeThreshold: 5, // min request sebelum menghitung error rate
}
const paymentBreaker = new CircuitBreaker(callPaymentAPI, options)
// Event listeners untuk monitoring
paymentBreaker.on('open', () => {
logger.warn('Payment circuit OPENED')
metrics.increment('circuit.opened', { circuit: 'payment' })
})
paymentBreaker.on('halfOpen', () => {
logger.info('Payment circuit HALF-OPEN — probing')
})
paymentBreaker.on('close', () => {
logger.info('Payment circuit CLOSED — recovered')
})
// Fallback: dipanggil saat circuit OPEN
paymentBreaker.fallback((orderID, amount) => {
return queuePaymentForLater(orderID, amount)
})
// Penggunaan
app.post('/checkout', async (req, res) => {
try {
const result = await paymentBreaker.fire(req.body.orderID, req.body.amount)
res.json(result)
} catch (err) {
res.status(503).json({ error: 'Payment service temporarily unavailable' })
}
})
Konfigurasi Threshold yang Tepat #
Konfigurasi yang salah bisa membuat circuit breaker tidak efektif atau terlalu agresif.
Panduan memilih threshold:
failure_threshold (berapa gagal sebelum OPEN):
→ Terlalu rendah (1-2): circuit terbuka untuk fluke error, terlalu agresif
→ Terlalu tinggi (50+): terlambat mendeteksi masalah nyata
→ Rekomendasi: 5-10 kegagalan dalam window 60 detik
→ Atau: failure rate (50-60%) dengan minimum volume request
failure_window (berapa lama menghitung kegagalan):
→ Terlalu pendek (5 detik): kegagalan lama masih menumpuk
→ Terlalu panjang (10 menit): terlambat reset setelah masalah selesai
→ Rekomendasi: 60 detik untuk kebanyakan kasus
recovery_timeout (berapa lama OPEN sebelum probe):
→ Sesuaikan dengan waktu recovery yang diharapkan dari dependency
→ Database restart: ~30 detik
→ External API: ~60 detik (beri waktu mereka menyadari masalah)
→ Rekomendasi: mulai dari 30-60 detik, adjust berdasarkan SLA dependency
success_threshold (berapa success sebelum CLOSED):
→ Terlalu rendah (1): bisa langsung CLOSED meski belum stabil
→ Rekomendasi: 2-3 untuk confidence yang cukup
Jangan gunakan angka yang sama untuk semua:
→ Payment API yang kritis: threshold lebih ketat, recovery lebih hati-hati
→ Cache layer yang optional: threshold lebih longgar
→ Internal service yang cepat: window yang lebih pendek
Bulkhead Pattern: Melengkapi Circuit Breaker #
Circuit breaker melindungi dari cascade failure karena kualitas yang buruk. Bulkhead melindungi dari cascade failure karena kuantitas yang berlebihan — mencegah satu dependency yang lambat menghabiskan semua thread/connection.
from concurrent.futures import ThreadPoolExecutor
import threading
class BulkheadExecutor:
"""
Bulkhead: isolasi thread pool per dependency.
Mencegah satu dependency lambat menghabiskan semua thread.
"""
def __init__(self, name: str, max_workers: int, queue_size: int = 0):
self.name = name
self._executor = ThreadPoolExecutor(
max_workers=max_workers,
thread_name_prefix=f"bulkhead-{name}"
)
self._semaphore = threading.Semaphore(max_workers + queue_size)
def submit(self, func, *args, **kwargs):
"""Submit task ke pool yang terisolasi."""
if not self._semaphore.acquire(blocking=False):
raise BulkheadFullError(
f"Bulkhead '{self.name}' is full "
f"(max_workers + queue_size reached)"
)
def wrapped():
try:
return func(*args, **kwargs)
finally:
self._semaphore.release()
return self._executor.submit(wrapped)
class BulkheadFullError(Exception):
pass
# Setup: setiap dependency punya thread pool sendiri
payment_pool = BulkheadExecutor("payment", max_workers=10, queue_size=5)
inventory_pool = BulkheadExecutor("inventory", max_workers=20, queue_size=10)
email_pool = BulkheadExecutor("email", max_workers=5, queue_size=50)
# Jika payment API lambat, hanya 10 thread yang terpengaruh
# Inventory dan email tetap berjalan normal dengan pool mereka sendiri
def process_checkout(order):
try:
payment_future = payment_pool.submit(charge_payment, order)
inventory_future = inventory_pool.submit(reserve_inventory, order)
payment_result = payment_future.result(timeout=15)
inventory_result = inventory_future.result(timeout=10)
return finalize_order(order, payment_result, inventory_result)
except BulkheadFullError:
return {'error': 'Service busy, please try again'}
Monitoring Circuit Breaker #
Circuit breaker tanpa monitoring adalah circuit breaker yang tidak berguna — kamu tidak akan tahu kapan dan mengapa sirkuit terbuka.
# Metrics yang perlu di-expose untuk setiap circuit breaker
class MonitoredCircuitBreaker(CircuitBreaker):
"""CircuitBreaker dengan Prometheus metrics."""
def __init__(self, name: str, config: CircuitBreakerConfig = None):
super().__init__(name, config)
# Prometheus counters dan gauges
self._requests_total = Counter(
'circuit_breaker_requests_total',
'Total requests through circuit breaker',
['circuit', 'result'] # result: success, failure, rejected
)
self._state_gauge = Gauge(
'circuit_breaker_state',
'Current state (0=closed, 1=open, 2=half_open)',
['circuit']
)
self._failure_count_gauge = Gauge(
'circuit_breaker_failure_count',
'Current failure count in window',
['circuit']
)
self.on_state_change = self._update_state_metric
def _update_state_metric(self, name, old_state, new_state):
state_map = {
CircuitState.CLOSED: 0,
CircuitState.OPEN: 1,
CircuitState.HALF_OPEN: 2,
}
self._state_gauge.labels(circuit=name).set(state_map[new_state])
def call(self, func, *args, **kwargs):
try:
result = super().call(func, *args, **kwargs)
self._requests_total.labels(
circuit=self.name, result='success'
).inc()
return result
except CircuitOpenError:
self._requests_total.labels(
circuit=self.name, result='rejected'
).inc()
raise
except Exception:
self._requests_total.labels(
circuit=self.name, result='failure'
).inc()
raise
finally:
self._failure_count_gauge.labels(
circuit=self.name
).set(self.failure_count)
Alert yang perlu dipasang:
Alert kritis:
→ Circuit OPEN untuk dependency yang critical path
circuit_breaker_state{circuit="payment"} == 1
→ PagerDuty/on-call engineer
Alert warning:
→ Circuit OPEN untuk dependency yang non-critical
circuit_breaker_state{circuit="recommendation"} == 1
→ Slack notification
→ Failure rate naik mendekati threshold
circuit_breaker_failure_count > threshold * 0.8
→ Early warning sebelum circuit terbuka
Dashboard yang berguna:
→ State setiap circuit breaker (closed/open/half-open)
→ Failure rate per circuit per waktu
→ Jumlah request yang di-reject (circuit open)
→ Durasi circuit dalam state OPEN
Anti-Pattern yang Harus Dihindari #
# ✗ Anti-pattern 1: Circuit breaker yang tidak di-configure per dependency
# Satu circuit breaker global untuk semua call
global_breaker = CircuitBreaker("global") # JANGAN
# ✓ Solusi: circuit breaker terpisah per dependency
payment_breaker = CircuitBreaker("payment-gateway")
inventory_breaker = CircuitBreaker("inventory-service")
email_breaker = CircuitBreaker("email-service")
# ✗ Anti-pattern 2: Tidak ada fallback ketika circuit OPEN
def get_product(product_id):
return product_breaker.call(lambda: fetch_from_db(product_id))
# Jika circuit OPEN → exception propagate → 500 error ke user
# ✓ Solusi: selalu ada fallback
def get_product(product_id):
try:
return product_breaker.call(lambda: fetch_from_db(product_id))
except CircuitOpenError:
# Fallback: cek cache, atau return data minimal
cached = redis.get(f"product:{product_id}")
return json.loads(cached) if cached else {'id': product_id, 'status': 'limited'}
# ✗ Anti-pattern 3: Threshold yang terlalu sensitif
config = CircuitBreakerConfig(failure_threshold=1) # 1 error → OPEN
# Network fluke biasa langsung membuat circuit terbuka
# ✗ Anti-pattern 4: Recovery timeout yang terlalu lama
config = CircuitBreakerConfig(recovery_timeout=3600) # 1 jam
# Dependency pulih dalam 30 detik tapi circuit tetap OPEN 1 jam
# Pengguna mengalami degraded service tidak perlu selama itu
# ✗ Anti-pattern 5: Circuit breaker tanpa monitoring
# Tidak ada yang tahu circuit terbuka sampai user mengeluh
# ✗ Anti-pattern 6: Include semua exception sebagai failure
config = CircuitBreakerConfig(
expected_exceptions=(Exception,) # termasuk ValueError, TypeError
)
# Bug dalam kode (ValueError) dihitung sebagai "dependency failure"
# ✓ Solusi: hanya network dan HTTP error yang dihitung sebagai failure
config = CircuitBreakerConfig(
expected_exceptions=(httpx.HTTPError, ConnectionError, TimeoutError)
)
Checklist Circuit Breaker #
DESAIN:
□ Circuit breaker terpisah untuk setiap external dependency
□ Threshold dikonfigurasi berdasarkan karakteristik dependency (bukan satu nilai untuk semua)
□ Expected exceptions dikonfigurasi dengan benar (hanya network/timeout error)
□ Fallback strategy terdefinisi untuk setiap circuit breaker
KONFIGURASI:
□ failure_threshold: 5-10 kegagalan (atau 50-60% failure rate)
□ failure_window: 60 detik (sesuaikan dengan traffic pattern)
□ recovery_timeout: sesuai ekspektasi waktu recovery dependency
□ success_threshold: 2-3 untuk confidence yang cukup di HALF-OPEN
FALLBACK:
□ Ketika circuit OPEN, ada respons yang berguna (bukan hanya error)
□ Fallback di-test secara teratur (chaos engineering)
□ User mendapat pesan yang informatif tentang degraded service
□ Data stale dari cache digunakan sebagai fallback bila memungkinkan
MONITORING:
□ State setiap circuit breaker di-expose sebagai metric
□ Alert dipasang untuk circuit yang OPEN (terutama critical path)
□ Dashboard menampilkan health semua circuit breaker
□ Failure rate per circuit di-track untuk early warning
BULKHEAD:
□ Thread pool terpisah untuk dependency yang bisa lambat
□ Connection pool per dependency dikonfigurasi dengan tepat
□ Max concurrent calls per dependency dibatasi
Ringkasan #
- Circuit breaker mencegah cascade failure — kegagalan satu dependency tidak menyebar ke seluruh sistem. Request gagal cepat (fast fail) alih-alih menunggu timeout dan memblokir thread.
- Tiga state yang bekerja bersama — CLOSED (normal), OPEN (melindungi dependency), HALF-OPEN (probe recovery). Transisi otomatis berdasarkan threshold yang dikonfigurasi.
- Fast fail adalah manfaat utama — circuit yang OPEN mengembalikan error dalam mikrodetik, bukan menunggu timeout 30 detik. Ini menjaga thread pool tetap tersedia untuk request lain.
- Setiap dependency butuh circuit breaker sendiri — payment gateway, database, email service, search service — masing-masing punya karakteristik berbeda dan perlu threshold yang berbeda.
- Fallback adalah bagian yang tidak bisa diabaikan — circuit breaker tanpa fallback hanya memindahkan masalah dari “lambat” ke “error”. Fallback yang baik memberikan degraded tapi masih berguna.
- Threshold harus dikalibrasi — terlalu sensitif dan circuit terbuka untuk error kecil, terlalu longgar dan terlambat melindungi. Gunakan failure rate dengan minimum volume, bukan hanya jumlah absolut.
- Bulkhead melengkapi circuit breaker — circuit breaker melindungi dari kualitas yang buruk, bulkhead melindungi dari kuantitas yang berlebihan. Keduanya dibutuhkan untuk resilience yang komprehensif.
- Monitoring bukan opsional — tanpa metrics dan alert, circuit breaker yang terbuka tidak akan ketahuan sampai user mengeluh. State setiap circuit harus di-expose dan di-alert.
- Library yang teruji lebih baik dari implementasi sendiri — pybreaker, gobreaker, opossum sudah menangani edge case yang tidak terpikirkan. Gunakan library untuk production, implementasi sendiri hanya untuk memahami konsep.
- Expected exceptions harus dikonfigurasi dengan cermat — hanya network error, timeout, dan HTTP error yang menandakan masalah di dependency. Bug dalam kode sendiri (ValueError, TypeError) tidak boleh dihitung sebagai “dependency failure”.