Resiliência em Go: Timeouts, Exponential Backoff, Circuit Breaker e Bulkhead
Como proteger chamadas a serviços lentos com timeouts, retry com exponential backoff, circuit breaker e bulkhead, em Go e em produção.
Publicada em · 6 min de leitura
Resiliência em Go: Timeouts, Exponential Backoff, Circuit Breaker e Bulkhead
Em uma arquitetura de microsserviços distribuídos, falhas de rede e dependências lentas não são exceções; são certezas estatísticas. Quando uma API de terceiros (como um gateway de pagamentos ou consulta de CEP) começa a demorar 10 segundos para responder, cada requisição recebida pelo seu serviço segura uma goroutine, uma conexão HTTP e memória RAM.
Em segundos, o pool de conexões esgota-se, os recursos do pod são sufocados e a aplicação inteira para de responder. Esse fenômeno é conhecido como Falha em Cascata (Cascading Failure).
Para construir sistemas resilientes e tolerantes a falhas em Go, aplicamos um conjunto integrado de padrões de estabilidade: Timeouts Estritos, Retentativas com Backoff e Jitter, Circuit Breakers e Bulkheads.
1. O Circuito Elétrico da Aplicação: Circuit Breaker
Inspirado nos disjuntores elétricos residenciais que desarmam para evitar que uma sobrecarga queime os aparelhos da casa, o padrão Circuit Breaker monitora chamadas a serviços externos. Se a taxa de erros ultrapassar um limite tolerável, o circuito abre (Open), cortando imediatamente as chamadas subsequentes e retornando um erro instantâneo (Fast-Fail) sem sobrecarregar o serviço combalido.
Os 3 Estados do Circuito:
- Closed (Fechado - Operação Normal): Todas as chamadas são executadas normalmente. Falhas e sucessos são contabilizados em uma janela de tempo.
- Open (Aberto - Curto-Circuito): Quando a taxa de falhas atinge o limiar configurado (ex: 50% de erros ou 5 falhas consecutivas), o circuito desarma. Todas as requisições falham imediatamente com
ErrOpenState, sem gastar I/O de rede nem travar goroutines. - Half-Open (Semi-Aberto - Teste Canário): Após uma janela de repouso (Sleep Window, ex: 30 segundos), o circuito permite a passagem de uma quantidade restrita de requisições de teste. Se essas chamadas tiverem sucesso, o circuito desarma de volta para Closed; se falharem, ele reabre para mais um período em Open.
2. A Armadilha do Thundering Herd e o Jitter
Retentar uma requisição que falhou é essencial para tolerar instabilidades passageiras de rede. No entanto, retentativas ingênuas causam um dos desastres operacionais mais conhecidos da engenharia de software: o Efeito Manada (Thundering Herd ou Retry Stampede).
Se 1.000 clientes falharem no mesmo instante e todos retentarem com um intervalo fixo de exatamente 2 segundos, o servidor receberá um tsunami sincronizado de 1.000 requisições simultâneas a cada 2 segundos, impedindo qualquer chance de recuperação.
A Solução Matemática: Exponential Backoff com Full Jitter
Introduzimos um atraso exponencial acrescido de uma dispersão aleatória (Jitter):
$$t_{\text{sleep}} = \text{random}(0, \min(t_{\text{max}}, t_{\text{base}} \times 2^{\text{tentativa}}))$$
package resilience
import (
"context"
"math/rand"
"time"
)
// RetryWithBackoff executa a função com backoff exponencial e dispersão aleatória (Full Jitter)
func RetryWithBackoff(ctx context.Context, maxAttempts int, baseDelay, maxDelay time.Duration, op func() error) error {
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
// Executa a operação
lastErr = op()
if lastErr == nil {
return nil // Sucesso absoluto
}
// Se atingiu o limite de tentativas, encerra
if attempt == maxAttempts-1 {
break
}
// Calcula o atraso exponencial: base * 2^attempt
delay := baseDelay * (1 << attempt)
if delay > maxDelay {
delay = maxDelay
}
// Aplica Full Jitter: escolhe um valor aleatório entre 0 e delay
jitter := time.Duration(rand.Int63n(int64(delay)))
// Aguarda o jitter respeitando cancelamento de contexto
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(jitter):
}
}
return lastErr
}
3. Implementando Circuit Breaker com sony/gobreaker
A biblioteca mais adotada e testada em produção no Go é o sony/gobreaker:
package gateway
import (
"errors"
"fmt"
"time"
"github.com/sony/gobreaker"
)
type PaymentClient struct {
cb *gobreaker.CircuitBreaker
}
func NewPaymentClient() *PaymentClient {
settings := gobreaker.Settings{
Name: "StripePaymentGateway",
MaxRequests: 3, // Requisições simultâneas permitidas no estado Half-Open
Interval: 10 * time.Second, // Janela cíclica de estatísticas
Timeout: 30 * time.Second, // Tempo que permanece no estado Open antes de testar Half-Open
ReadyToTrip: func(counts gobreaker.Counts) bool {
// Dispara abertura se houver mais de 5 requisições e a taxa de falha exceder 50%
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 5 && failureRatio >= 0.50
},
OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
fmt.Printf("Circuit Breaker [%s] mudou de estado: %s -> %s\n", name, from, to)
},
}
return &PaymentClient{
cb: gobreaker.NewCircuitBreaker(settings),
}
}
func (c *PaymentClient) ProcessPayment(amount float64) (string, error) {
// cb.Execute protege a chamada
result, err := c.cb.Execute(func() (any, error) {
// Chamada HTTP real ao gateway externo...
return executeHTTPCall(amount)
})
if err != nil {
// Se o circuito estiver aberto, aplicamos um Fallback Gracioso
if errors.Is(err, gobreaker.ErrOpenState) {
return "", errors.New("serviço de pagamentos temporariamente indisponível (circuito aberto); tente mais tarde")
}
return "", fmt.Errorf("falha no processamento: %w", err)
}
return result.(string), nil
}
func executeHTTPCall(amount float64) (string, error) {
// Lógica de transporte HTTP...
return "tx_success_123", nil
}
4. O Padrão Bulkhead: Compartimentando Recursos
Em navios cargueiros, o casco é dividido em compartimentos estanques chamados Bulkheads. Se um compartimento for perfurado e inundar, a água não se espalha para os outros, e o navio continua flutuando.
Na engenharia de software, o padrão Bulkhead isola a capacidade de processamento de diferentes operações para que um serviço terceiro com lentidão não consuma todas as goroutines do seu servidor.
Implementação de Bulkhead com Canal e Load Shedding
Em Go, canais com buffer funcionam nativamente como semáforos eficientes:
package resilience
import "errors"
var ErrBulkheadFull = errors.New("limite de capacidade do compartimento excedido (bulkhead cheio)")
type Bulkhead struct {
sem chan struct{}
}
func NewBulkhead(maxConcurrent int) *Bulkhead {
return &Bulkhead{
sem: make(chan struct{}, maxConcurrent),
}
}
// Execute roda a tarefa se houver capacidade; caso contrário, descarta imediatamente (Load Shedding)
func (b *Bulkhead) Execute(fn func() error) error {
select {
case b.sem <- struct{}{}:
// Slot adquirido com sucesso
defer func() { <-b.sem }()
return fn()
default:
// Rejeição imediata sem fila e sem espera
return ErrBulkheadFull
}
}
Se sua API aceita 10.000 requisições simultâneas, você pode configurar o Bulkhead do serviço de recomendação para no máximo 50 conexões simultâneas. Se o recomendador travar, apenas as 50 chamadas sofrerão descarte; os outros 9.950 usuários de checkout continuarão comprando sem nenhum impacto.
5. A Cadeia Completa de Resiliência
Em um cliente HTTP corporativo, combinamos todos os padrões em camadas complementares:
Requisição de Negócio
└── [1. Bulkhead] -> Garante que não ultrapassamos o teto de concorrência
└── [2. Circuit Breaker] -> Garante que não chamamos se o alvo estiver fora
└── [3. Retry + Jitter] -> Trata soluços transitórios de rede
└── [4. Timeout] -> Aborta se uma tentativa demorar mais de 2s
└── Socket TCP
6. Resumo Conceitual
- Timeouts (
context.WithTimeout) são a primeira e mais obrigatória linha de defesa de qualquer chamada externa. - Retentativas devem sempre utilizar Exponential Backoff com Full Jitter para evitar o fenômeno destrutivo do Thundering Herd.
- Nunca retente erros definitivos de cliente (HTTP 4xx); retente apenas erros de rede e respostas de indisponibilidade transitória (HTTP 5xx).
- O Circuit Breaker alterna entre os estados Closed, Open e Half-Open, falhando instantaneamente (Fast-Fail) quando um serviço dependente colapsa.
- O padrão Bulkhead compartimenta a concorrência através de semáforos Go, protegendo as operações vitais da aplicação do efeito dominó de dependências secundárias com problemas.