Todo circuit breaker em produção que analisei eventualmente cria uma thread em background. Pode ser uma goroutine em Go, um ScheduledExecutorService em Java, ou uma task do tokio em Rust. O trabalho é sempre o mesmo: acordar a cada poucos segundos, verificar se o downstream service se recuperou, e transicionar de OPEN de volta para CLOSED.

Esse design está errado. Ele vaza recursos em escala, complica o shutdown, e cria race conditions que são genuinamente difíceis de testar. Pior, o trabalho em background é completamente desnecessário. Você pode construir um circuit breaker que nunca acorda por conta própria, nunca aloca um timer, e ainda assim detecta corretamente a recuperação.

O custo oculto das goroutines de health-check

Um circuit breaker rastreia falhas. Depois de erros consecutivos suficientes, ele dispara para OPEN e começa a rejeitar requests imediatamente. O objetivo é dar uma folga para o serviço que está falhando em vez de afogá-lo em tráfego de retry.

A parte complicada é decidir quando tentar novamente. A maioria das bibliotecas resolve isso com um setTimeout ou time.AfterFunc. Em Go, uma implementação típica se parece com isso:

func (cb *CircuitBreaker) Trip() {
    cb.state.Store(StateOpen)
    time.AfterFunc(cb.timeout, func() {
        cb.state.Store(StateHalfOpen)
    })
}

Isso funciona para um único breaker. Não funciona para dez mil.

Se você criar um circuit breaker por downstream host (um padrão comum em microservices), agora você tem dez mil goroutines dormindo em background. Cada goroutine custa ~2 KB de stack space e adiciona overhead de scheduling. Em restarts de containers, essas goroutines competem contra deadlines de shutdown. Em timeouts, elas disparam exatamente no momento errado e criam flapping.

A thread em background está resolvendo um problema que não existe. A recuperação não precisa ser detectada proativamente. Ela pode ser detectada de forma lazy, no request path.

Como funciona a lazy recovery

Em vez de um timer que transiciona o breaker, armazene um único timestamp: o momento em que o breaker disparou para OPEN. Em cada request de entrada, compare now contra esse timestamp mais o timeout configurado. Se tempo suficiente tiver passado, permita que um único probe passe. Se o probe tiver sucesso, feche o breaker. Se falhar, atualize o timestamp e permaneça em OPEN.

A state machine permanece idêntica. Apenas o trigger de transição muda.

  • CLOSED: requests passam. Falhas incrementam um counter. Quando o counter atinge o threshold, troque atomicamente para OPEN e registre trippedAt.
  • OPEN: cada request de entrada verifica time.Now() > trippedAt + timeout. Se falso, fail fast. Se verdadeiro, troque atomicamente para HALF-OPEN e deixe esse único request passar.
  • HALF-OPEN: exatamente um request está em voo. Se tiver sucesso, troque para CLOSED e resete o failure counter. Se falhar, volte para OPEN e atualize trippedAt.

Nenhuma goroutine acorda. Nenhum timer é alocado. O breaker é completamente passivo até que um request chegue.

Uma implementação funcional em Go

Aqui está um circuit breaker completo, com zero background. Ele usa apenas sync/atomic para transições de estado e armazena o timestamp de disparo como um counter de nanosegundos.

package breaker

import (
	"errors"
	"sync/atomic"
	"time"
)

type State int32

const (
	StateClosed State = iota
	StateOpen
	StateHalfOpen
)

type CircuitBreaker struct {
	// state is accessed with atomic operations.
	state      int32
	failures   int32
	threshold  int32
	timeout    time.Duration
	trippedAt  int64 // nanoseconds since Unix epoch
}

func New(threshold int, timeout time.Duration) *CircuitBreaker {
	return &CircuitBreaker{
		threshold: int32(threshold),
		timeout:   timeout,
	}
}

func (cb *CircuitBreaker) State() State {
	return State(atomic.LoadInt32(&cb.state))
}

// Allow reports whether the current request may proceed.
// It returns a done function that must be called with the outcome.
func (cb *CircuitBreaker) Allow() (done func(success bool), err error) {
	switch State(atomic.LoadInt32(&cb.state)) {
	case StateClosed:
		return cb.trackClosed, nil

	case StateOpen:
		// Lazy recovery check: has the timeout elapsed?
		if time.Now().UnixNano()-atomic.LoadInt64(&cb.trippedAt) < int64(cb.timeout) {
			return nil, errors.New("circuit breaker is open")
		}
		// Race: multiple goroutines may see this simultaneously.
		// Only one wins the CAS to HALF-OPEN.
		if atomic.CompareAndSwapInt32(&cb.state, int32(StateOpen), int32(StateHalfOpen)) {
			return cb.trackHalfOpen, nil
		}
		// Another goroutine won the race; fail fast this request.
		return nil, errors.New("circuit breaker is open")

	case StateHalfOpen:
		// Only one probe at a time. Every other request fails fast.
		return nil, errors.New("circuit breaker is half-open")
	}

	return nil, errors.New("unknown circuit breaker state")
}

func (cb *CircuitBreaker) trackClosed(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		return
	}

	// Increment failures and trip if threshold reached.
	if atomic.AddInt32(&cb.failures, 1) >= cb.threshold {
		// Record the trip time before switching state so readers
		// never see OPEN with a stale trippedAt.
		atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
		atomic.StoreInt32(&cb.state, int32(StateOpen))
	}
}

func (cb *CircuitBreaker) trackHalfOpen(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		atomic.StoreInt32(&cb.state, int32(StateClosed))
		return
	}

	atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
	atomic.StoreInt32(&cb.state, int32(StateOpen))
}

O invariante chave: trippedAt é sempre escrito antes de a transição de estado ir para OPEN. Readers em Allow() podem então ler trippedAt com segurança depois de ver OPEN, sabendo que ele está atualizado. No caminho de retorno de HALF-OPEN, atualizamos trippedAt antes de cair de volta para OPEN para que o cooldown recomece do zero.

Por que a maioria das bibliotecas não faz isso

O design lazy tem uma desvantagem aparente: a recuperação só é detectada quando um request chega. Se o seu serviço não receber tráfego por uma hora, o breaker permanece em OPEN por uma hora.

Isso parece ruim. Não é.

Se não há requests, não há nada para proteger. O breaker existe para prevenir cascading failures durante o tráfego, não para manter um health dashboard em tempo real. Quando o próximo request chegar, a verificação de timeout roda em nanosegundos e o probe dispara imediatamente. A latência efetiva de recuperação é limitada por max(timeout, time-between-requests).

Para serviços de alto tráfego, o intervalo entre requests é negligenciável. Para serviços de baixo tráfego, o timeout domina de qualquer forma. O timer em background quase nunca melhora o tempo real de recuperação na prática.

A outra razão pelas quais as bibliotecas usam timers é histórica. O padrão de circuit breaker foi popularizado em ambientes (Java com Hystrix, .NET com Polly) onde uma única instância de breaker guardava uma dependência de serviço inteira, não uma conexão por host. Uma thread em background era aceitável. Em sistemas distribuídos modernos, onde você pode ter um breaker por endpoint upstream, essa suposição desmorona.

Testando as race conditions

O CAS loop na transição de OPEN para HALF-OPEN é o único lugar onde as goroutines disputam. Se dois requests chegarem simultaneamente depois do timeout, apenas um prossegue como probe. O outro faz fail fast e retenta no próximo request. Esse é o comportamento correto. Você nunca quer múltiplos probes em voo durante a recuperação, porque uma única falha entre vários sucessos ainda poderia te jogar de volta para OPEN.

Testar é direto porque não há timers assíncronos. Você pode escrever um unit test que manipula trippedAt diretamente (ou usa um wrapper de tempo) sem dormir:

func TestLazyRecovery(t *testing.T) {
	cb := New(1, time.Minute)

	// Trip the breaker.
	done, _ := cb.Allow()
	done(false)

	if cb.State() != StateOpen {
		t.Fatal("expected OPEN")
	}

	// Simulate timeout by winding back trippedAt.
	atomic.StoreInt64(&cb.trippedAt, time.Now().Add(-2*time.Minute).UnixNano())

	done, err := cb.Allow()
	if err != nil {
		t.Fatalf("expected probe to be allowed: %v", err)
	}

	// Success closes the breaker.
	done(true)
	if cb.State() != StateClosed {
		t.Fatal("expected CLOSED after successful probe")
	}
}

Sem time.Sleep. Sem sync.WaitGroup para goroutines. O teste é determinístico porque a implementação é síncrona.

O que abrimos mão

Há uma perda real: você não pode pré-aquecer um breaker eagermente antes de enviar tráfego. Se você precisar fazer probe de uma dependência em um schedule fixo (digamos, a cada 5 segundos) para manter um connection pool warm, você ainda precisa de um timer. Mas esse timer pertence ao seu connection pool ou health checker, não ao circuit breaker. O breaker deve proteger o pool. Ele não deve gerenciá-lo.

Mantenha as responsabilidades separadas. Health checking aquece conexões. Circuit breaking previne overload em cascata. Quando você os funde, você obtém complexidade em ambos os lugares.

Um padrão para outras linguagens

A mesma estrutura funciona em qualquer lugar onde você tenha atomic compare-and-swap e um relógio monotônico. Em Rust com std::sync::atomic, em Java com AtomicIntegerFieldUpdater e System.nanoTime(), em C++ com std::atomic e um enum customizado. A implementação tem menos de cem linhas em todos os casos.

Se a sua linguagem não expõe CAS, um sync.Mutex (ou equivalente) ainda é mais barato que uma thread em background. O mutex é segurado apenas por nanosegundos por request, e apenas durante transições de estado. Ele nunca bloqueia por I/O ou dorme.

Experimente

A implementação completa acima está pronta para produção como ponto de partida. Adicione metrics, logging, e thresholds adaptativos por cima. Mas deixe de fora a goroutine. O seu runtime scheduler vai te agradecer, seus testes vão rodar mais rápido, e você vai parar de se perguntar por que aquele container se recusa a fazer shutdown de forma limpa.