Circuit Breaker in Go: proteggere i sistemi distribuiti dai fallimenti a catena

Costruire dei sistemi distribuiti veramente resilienti può essere una bella sfida. A volte un servizio inizia ad essere lento o a restituire errori. Senza protezioni, chi lo chiama continua a provarci per ogni richiesta in arrivo, causando connessioni che si accumulano, timeout e risorse esaurite. Il servizio a monte stava già male ma ora si sta affossando anche chi lo chiama causando quello che in gergo tecnico si chiama cascading failure.
Qui il circuit breaker può venirci in aiuto, con un pattern architetturale che interrompe questa catena di failure.
I tre stati
Il pattern prende il nome dagli interruttori elettrici, nei quali se c’è un sovraccarico, apri il circuito per proteggere il sistema.
CLOSED: tutto normale. Le chiamate passano. Il circuit breaker conta i fallimenti.
OPEN: troppi fallimenti in un certo intervallo. Il circuito si apre: le chiamate non vengono nemmeno tentate, falliscono immediatamente con un errore predefinito. Il servizio a valle si prende il tempo per riprendersi.
HALF-OPEN: dopo un timeout, il circuit breaker lascia passare una chiamata di test. Se va bene, torna CLOSED. Se fallisce, torna OPEN.
Tre parametri controllano il comportamento: quanti fallimenti aprono il circuito (failure threshold), quanto tempo restare in OPEN prima di riprovare (timeout), e quante chiamate di successo in HALF-OPEN servono per richiudersi (success threshold).
Implementazione in Go
Costruiamo una versione semplificata da zero che copre gli stati e la logica di transizione, sicuramente non production-ready, ma sufficiente per capire il pattern nel profondo.
package circuitbreaker
import (
"errors"
"sync"
"time"
)
type State int
const (
StateClosed State = iota
StateOpen
StateHalfOpen
)
var ErrCircuitOpen = errors.New("circuit breaker is open")
type CircuitBreaker struct {
mu sync.Mutex
state State
failureCount int
failureThreshold int
timeout time.Duration
lastFailureTime time.Time
}
func New(failureThreshold int, timeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
state: StateClosed,
failureThreshold: failureThreshold,
timeout: timeout,
}
}
Il sync.Mutex serve a proteggere il circuit breaker da scritture e letture concorrenti.
Tutta la logica vive nel metodo Execute, che wrappa la chiamata da proteggere:
func (cb *CircuitBreaker) Execute(fn func() error) error {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state == StateOpen {
if time.Since(cb.lastFailureTime) > cb.timeout {
cb.state = StateHalfOpen
} else {
return ErrCircuitOpen
}
}
err := fn()
if err != nil {
cb.failureCount++
cb.lastFailureTime = time.Now()
if cb.failureCount >= cb.failureThreshold {
cb.state = StateOpen
}
return err
}
cb.failureCount = 0
cb.state = StateClosed
return nil
}
Execute non sa cosa deve proteggere, potrebbe essere una chiamata HTTP, una query a un database, qualsiasi cosa. Riceve una funzione generica (func() error), decide se eseguirla in base allo stato corrente, e gestisce il risultato.
cb := circuitbreaker.New(3, 10*time.Second)
err := cb.Execute(func() error {
return chiamataAlServizioEsterno()
})
if errors.Is(err, circuitbreaker.ErrCircuitOpen) {
// fallback immediato, nessuna attesa
}
Cosa succede davvero: un test con fallimenti simulati
Per vedere il pattern in azione serve un servizio che fallisca in modo prevedibile:
var callCount int
func callExternalService() error {
callCount++
if callCount <= 5 {
return fmt.Errorf("service unavailable (call #%d)", callCount)
}
return nil
}
Con un failureThreshold di 3 e un timeout di 2 secondi, chiamando Execute dieci volte con una pausa di un secondo tra un tentativo e l’altro, l’output è questo:
Attempt 1: ERROR -> service unavailable (call #1)
Attempt 2: ERROR -> service unavailable (call #2)
Attempt 3: ERROR -> service unavailable (call #3)
Attempt 4: ERROR -> circuit breaker is open
Attempt 5: ERROR -> service unavailable (call #4)
Attempt 6: ERROR -> circuit breaker is open
Attempt 7: ERROR -> service unavailable (call #5)
Attempt 8: ERROR -> circuit breaker is open
Attempt 9: OK
Attempt 10: OK
Vale la pena leggerlo riga per riga. I primi tre tentativi falliscono e portano il circuito in OPEN. Il quarto tentativo viene bloccato senza nemmeno chiamare il servizio; callCount infatti resta fermo a 3. Al quinto tentativo sono passati i 2 secondi di timeout: il circuito prova HALF-OPEN, la chiamata parte (call #4) ma fallisce ancora, e il circuito torna immediatamente OPEN. Lo stesso ciclo si ripete al settimo tentativo. Solo all’ottavo la chiamata di prova (call #6) ha successo, e il circuito si richiude per restare CLOSED.
Quando ha senso usarlo
Il circuit breaker ha senso quando chiami servizi esterni che possono degradare, come API di terze parti, database sotto carico, microservizi con SLA non garantiti. Non ha senso per chiamate locali o per errori che non hanno un pattern temporale (un dato malformato non migliora aspettando).
In produzione vale la pena guardare librerie mature come gobreaker, che aggiungono success threshold configurabile, metriche e callback sugli stati. Ma costruire la versione minimale da zero, come abbiamo fatto qui, è il modo più diretto per capire cosa sta succedendo sotto, prima di affidarsi a un pacchetto che lo nasconde.
Link a repository https://github.com/steamedeo/circuit-breaker











