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

Immagine di copertina

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

Conosci meglio chi ha scritto questo articolo

Stefano Amedeo

Leader tecnico con oltre 10 anni di esperienza in cloud, agile delivery e trasformazione digitale. Appassionato di risoluzione dei problemi aziendali e creazione di valore per l’utente.

Foto di Stefano Amedeo

Partners

Community, aziende e persone che supportano attivamente il blog

Vuoi diventare partner?

Collaboriamo con community, aziende e strumenti del mondo tech che vogliono raggiungere la nostra community di dev ed engineer italiani. Scrivici per proporre una collaborazione.

✉️ Scrivici

Vuoi diventare tech content creator? 🖊️

Se ti va di raccontare la tua esperienza nel mondo tech, questo è il posto giusto.

Cerchiamo voci autentiche, esempi pratici e punti di vista utili per chi legge.

Scrivici a collaborazioni[at]theredcode.it con una proposta: idea, taglio del contenuto e una breve presentazione. Non vediamo l'ora di leggere la tua esperienza!

Invia la tua idea