Pular para o conteúdo

Trilha de Go · parte 33 de 37

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

Tamanho da fonte:
Largura da coluna:

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.

Máquina de Estados do Circuit Breaker
figura 1 · Máquina de Estados do Circuit Breaker

Os 3 Estados do Circuito:

  1. Closed (Fechado - Operação Normal): Todas as chamadas são executadas normalmente. Falhas e sucessos são contabilizados em uma janela de tempo.
  2. 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.
  3. 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).

Retentativas Sem Jitter vs Com Jitter
figura 2 · Retentativas Sem Jitter vs Com Jitter

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.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?