Voltar ao blog
18 de maio de 20265 min de leitura

Circuit Breaker Pattern em Go: Protegendo Chamadas a LLMs

godesignpatternsresilienceprogramming

Você tem um orquestrador que chama um serviço externo de LLM dezenas de vezes por sessão. Esse serviço depende de uma API remota. APIs remotas caem. Quando caem, seu orquestrador fica travado esperando timeout de 120 segundos por chamada, acumula 5 chamadas na fila, e o usuário espera 10 minutos para receber um erro genérico. Pior: enquanto o serviço está fora, você continua bombardeando ele com requests, o que pode atrasar ainda mais a recuperação. O Circuit Breaker resolve isso cortando as chamadas quando detecta que o serviço está instável, e retomando gradualmente quando ele volta.

Guia de tópicos:

  • O Problema: Cascata de Falhas em Chamadas Externas
  • Os Três Estados do Circuit Breaker
  • Implementação em Go com Mutex
  • Integrando com o Orquestrador
  • Threshold e Timeout: Como Calibrar
  • Half-Open: A Recuperação Gradual
  • Considerações Finais
  • Links Indicativos

O Problema: Cascata de Falhas em Chamadas Externas

Em orquestradores de agentes, cada requisição do usuário pode gerar entre 1 e 6 chamadas ao serviço externo dependendo da complexidade. Uma requisição simples é 1 chamada. Uma coordenação multi-agente com análise, implementação e validação são 6 ou mais. Cada chamada tem timeout de 120 segundos.

Quando o serviço está saudável, cada chamada leva entre 3 e 15 segundos. Quando está degradado (API com latência alta), leva 30 a 60 segundos. Quando está fora, leva 120 segundos para dar timeout. Se o usuário manda uma requisição multi-agente e o serviço está fora, são 6 chamadas × 120 segundos = 12 minutos de espera para receber um erro. Isso é inaceitável.

O segundo problema é mais sutil. Se o serviço está sobrecarregado — não fora, mas lento — continuar mandando requests piora a situação. Você está contribuindo para a sobrecarga. É como todo mundo buzinar no trânsito: não resolve nada e piora o estresse de quem está tentando resolver o problema.


Os Três Estados do Circuit Breaker

O pattern tem três estados que formam uma máquina de estados simples:

Closed é o estado normal. Todas as chamadas passam. O circuit breaker conta as falhas consecutivas. Quando o número de falhas atinge o threshold, transiciona para Open.

Open é o estado de proteção. Nenhuma chamada passa. O sistema retorna erro imediatamente sem nem tentar chamar o serviço externo. Depois de um timeout configurado, transiciona para Half-Open.

Half-Open é o estado de teste. Uma única chamada é permitida para verificar se o serviço voltou. Se essa chamada sucede, volta para Closed. Se falha, volta para Open.

Closed → (N falhas) → Open → (timeout) → Half-Open → (sucesso) → Closed
                                                    → (falha)  → Open

Implementação em Go com Mutex

A implementação é direta. O struct mantém o estado, o contador de falhas, o timestamp da última falha, e as configurações:

type CircuitBreaker struct {
    failures    int
    lastFailure time.Time
    state       CircuitState
    threshold   int
    timeout     time.Duration
    mutex       sync.RWMutex
}

type CircuitState int

const (
    Closed CircuitState = iota
    Open
    HalfOpen
)

func NewCircuitBreaker(threshold int, timeout time.Duration) *CircuitBreaker {
    return &CircuitBreaker{
        threshold: threshold,
        timeout:   timeout,
        state:     Closed,
    }
}

O método principal é CanExecute, que decide se uma chamada pode passar:

func (cb *CircuitBreaker) CanExecute() bool {
    cb.mutex.RLock()
    defer cb.mutex.RUnlock()

    switch cb.state {
    case Closed:
        return true
    case Open:
        if time.Since(cb.lastFailure) > cb.timeout {
            cb.state = HalfOpen
            return true
        }
        return false
    case HalfOpen:
        return true
    }
    return false
}

Quando o estado é Open, ele verifica se já passou tempo suficiente desde a última falha. Se passou, transiciona para Half-Open e permite uma tentativa. Se não passou, retorna false imediatamente sem latência.

Os métodos de feedback são chamados após cada execução:

func (cb *CircuitBreaker) RecordSuccess() {
    cb.mutex.Lock()
    defer cb.mutex.Unlock()

    cb.failures = 0
    cb.state = Closed
}

func (cb *CircuitBreaker) RecordFailure() {
    cb.mutex.Lock()
    defer cb.mutex.Unlock()

    cb.failures++
    cb.lastFailure = time.Now()

    if cb.failures >= cb.threshold {
        cb.state = Open
    }
}

Sucesso reseta tudo. Falha incrementa o contador e, se atingiu o threshold, abre o circuito. O mutex é necessário porque múltiplas goroutines podem estar executando chamadas simultaneamente — o que acontece naturalmente em workflows com steps paralelos.


Integrando com o Orquestrador

No orquestrador, o circuit breaker envolve toda a lógica de processamento:

func (eo *EnhancedOrchestrator) ProcessWithIntelligence(ctx context.Context, input string) error {
    // Check circuit breaker antes de qualquer processamento
    if !eo.circuitBreaker.CanExecute() {
        return fmt.Errorf("circuit breaker is open, service temporarily unavailable")
    }

    // Tenta executar o workflow
    result, err := eo.executeWorkflowWithStreaming(ctx, workflow, input)
    if err != nil {
        eo.circuitBreaker.RecordFailure()
        return err
    }

    // Sucesso: reseta o circuit breaker
    eo.circuitBreaker.RecordSuccess()
    return nil
}

O ponto importante é onde o check acontece: antes de qualquer processamento. Se o circuito está aberto, o usuário recebe feedback instantâneo ("serviço temporariamente indisponível") em vez de esperar minutos para um timeout. A experiência muda de "travou" para "está fora, tenta daqui a pouco".


Threshold e Timeout: Como Calibrar

No projeto que motivou este artigo, uso threshold de 5 falhas e timeout de 1 minuto:

circuitBreaker: NewCircuitBreaker(5, 1*time.Minute)

Por que 5? Porque uma falha isolada pode ser glitch de rede. Duas podem ser coincidência. Cinco consecutivas indicam um problema real. Se o threshold for 1, o circuito abre com qualquer oscilação momentânea. Se for 20, o usuário sofre 20 timeouts antes do sistema reagir.

Por que 1 minuto? Porque a maioria dos problemas em serviços de LLM são transientes — rate limiting, cold start, pico de carga. Em 1 minuto geralmente já normalizou. Se o timeout for 10 segundos, o circuito vai ficar oscilando entre Open e Half-Open sem dar tempo do serviço se recuperar. Se for 10 minutos, o usuário fica bloqueado tempo demais quando o problema já passou.

Esses valores são para um CLI interativo onde um usuário está esperando resposta. Para um sistema batch que processa milhares de requests, os valores seriam diferentes: threshold mais alto (tolera mais falhas antes de parar) e timeout mais curto (tenta retomar mais rápido porque tem fila acumulando).


Half-Open: A Recuperação Gradual

O estado Half-Open é o que diferencia um circuit breaker de um simples "desliga e liga depois de X tempo". Em vez de abrir a comporta toda de uma vez — o que poderia sobrecarregar o serviço que acabou de voltar — ele permite uma única chamada de teste.

Se essa chamada sucede, o serviço voltou e o circuito fecha. Se falha, o serviço ainda está fora e o circuito reabre por mais um período de timeout. Isso evita o cenário onde o serviço volta parcialmente (aceita 1 request por segundo mas não 100) e o circuit breaker joga toda a carga de volta nele imediatamente.

No contexto de um orquestrador interativo, quando o circuito está em Half-Open e o usuário manda um comando, esse comando serve como probe. Se funcionar, ótimo, o sistema volta ao normal. Se não funcionar, o usuário recebe erro rápido e o sistema espera mais um ciclo de timeout antes de tentar de novo.

Uma melhoria possível é um probe automático em background: em vez de esperar o usuário mandar um comando para testar, o sistema poderia fazer um health check periódico e reabrir o circuito proativamente quando detectar que o serviço voltou.


Considerações Finais

Circuit Breaker é um pattern que parece simples demais para funcionar, mas na prática é a diferença entre um sistema que degrada graciosamente e um que trava por minutos quando algo dá errado. Em 50 linhas de Go você tem proteção contra cascata de falhas, feedback instantâneo para o usuário, e recuperação automática quando o serviço volta.

O que importa levar deste artigo: resiliência não precisa ser complexa. Não precisa de Hystrix, não precisa de service mesh, não precisa de sidecar proxy. Um struct com três estados, um mutex e dois métodos resolve o problema para a maioria dos cenários. Adicione complexidade — métricas, alertas, configuração dinâmica — só quando o cenário simples não for suficiente.

E um ponto que muita gente esquece: o circuit breaker protege o serviço externo tanto quanto protege o seu sistema. Quando você para de mandar requests para um serviço sobrecarregado, você está dando tempo para ele se recuperar. É cooperação, não só autodefesa.


Links indicativos: