← Voltar para o blog

Circuit Breaker em Go: Resiliência para APIs Externas

Aprenda circuit breaker em Go para chamadas HTTP: estados, timeouts, fallback, half-open, métricas, testes e cuidados de produção.

Circuit breaker em Go é um padrão simples para um problema caro: impedir que uma dependência instável derrube o seu serviço por arrasto. Quando uma API de pagamento, antifraude, frete, nota fiscal, CRM ou autenticação começa a falhar, o pior comportamento é continuar abrindo chamadas sem limite, segurando goroutines, ocupando conexões e aumentando a latência de todos os usuários. O breaker corta o caminho antes que a falha vire efeito dominó.

Em português direto: circuit breaker é um disjuntor lógico. Enquanto a dependência responde bem, as chamadas passam. Quando os erros passam de um limite, o circuito abre e novas chamadas falham rápido. Depois de um tempo, ele deixa poucas tentativas passarem no modo half-open. Se a dependência se recuperou, fecha de novo. Se continua ruim, mantém o corte.

Este guia mostra como aplicar circuit breaker em Go para clients HTTP, com context.Context, timeouts, fallback, métricas, logs, testes e cuidados de produção. Ele complementa context, timeout e cancelamento, httptrace para debug de cliente HTTP, rate limiting em Go, OpenTelemetry em Go e webhooks com idempotência.

Quando usar circuit breaker

Use circuit breaker quando a sua aplicação depende de algo que pode ficar lento, instável ou indisponível por alguns minutos: API externa, gateway de pagamento, serviço interno, banco secundário, cache remoto ou fornecedor B2B. O objetivo não é esconder erro. É proteger capacidade, degradar com clareza e dar tempo para a dependência se recuperar.

Bons sinais de que o padrão faz sentido:

  • chamadas HTTP externas aparecem no caminho crítico do request;
  • timeouts de fornecedor já causaram picos de latência;
  • retries aumentam a carga em vez de resolver;
  • dashboards mostram cascata de erro entre serviços;
  • usuários poderiam receber uma resposta parcial, fila ou mensagem de indisponibilidade controlada;
  • o time precisa de métrica clara de dependência aberta, fechada e em recuperação.

Não use breaker para mascarar bug determinístico do seu próprio código. Se toda chamada falha por payload inválido, credencial errada ou contrato quebrado, o conserto é validação e deploy, não disjuntor. Também não use como substituto para timeout. Uma chamada sem timeout pode prender o worker antes mesmo de o breaker contabilizar erro.

Estados: closed, open e half-open

O breaker opera em três estados:

  1. Closed: funcionamento normal. As chamadas passam e o breaker contabiliza sucesso, erro e timeout.
  2. Open: a taxa ou sequência de falhas passou do limite. O breaker falha rápido sem chamar a dependência.
  3. Half-open: depois de uma janela de espera, poucas chamadas de teste são permitidas. Se der certo, fecha. Se falhar, abre novamente.

Esse desenho é melhor que retry infinito. Retry pode ser útil para erro transitório, mas aumenta tráfego justamente quando o fornecedor está sob pressão. Circuit breaker reduz o volume, protege seus recursos e deixa o incidente mais previsível.

Um client HTTP com breaker

A forma mais comum em Go é encapsular o client externo em um tipo próprio. Assim, handler, worker e job não precisam saber se a proteção vem de biblioteca, métrica ou regra local. O exemplo abaixo usa github.com/sony/gobreaker, uma biblioteca pequena e conhecida no ecossistema Go.

go get github.com/sony/gobreaker
package antifraude

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "time"

    "github.com/sony/gobreaker"
)

type Client struct {
    baseURL string
    http    *http.Client
    breaker *gobreaker.CircuitBreaker
}

type Request struct {
    OrderID string `json:"order_id"`
    Amount  int64  `json:"amount"`
}

type Response struct {
    Approved bool   `json:"approved"`
    Reason   string `json:"reason"`
}

func New(baseURL string) *Client {
    st := gobreaker.Settings{
        Name:        "antifraude-http",
        MaxRequests: 3,
        Interval:    1 * time.Minute,
        Timeout:     30 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
            return counts.Requests >= 10 && failureRatio >= 0.50
        },
        OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
            // Troque por slog, métrica ou evento de observabilidade.
            fmt.Printf("breaker %s mudou de %s para %s\n", name, from, to)
        },
    }

    return &Client{
        baseURL: baseURL,
        http: &http.Client{
            Timeout: 5 * time.Second,
        },
        breaker: gobreaker.NewCircuitBreaker(st),
    }
}

func (c *Client) Analyze(ctx context.Context, req Request) (Response, error) {
    result, err := c.breaker.Execute(func() (any, error) {
        return c.call(ctx, req)
    })
    if err != nil {
        return Response{}, err
    }
    return result.(Response), nil
}

func (c *Client) call(ctx context.Context, payload Request) (Response, error) {
    body, err := json.Marshal(payload)
    if err != nil {
        return Response{}, err
    }

    httpReq, err := http.NewRequestWithContext(
        ctx,
        http.MethodPost,
        c.baseURL+"/v1/analyze",
        bytes.NewReader(body),
    )
    if err != nil {
        return Response{}, err
    }
    httpReq.Header.Set("Content-Type", "application/json")

    resp, err := c.http.Do(httpReq)
    if err != nil {
        return Response{}, err
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
        return Response{}, fmt.Errorf("antifraude indisponível: status %d", resp.StatusCode)
    }
    if resp.StatusCode >= 400 {
        return Response{}, fmt.Errorf("requisição inválida para antifraude: status %d", resp.StatusCode)
    }

    var out Response
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return Response{}, err
    }
    return out, nil
}

Repare em três decisões importantes. Primeiro, o http.Client tem timeout. Segundo, a chamada recebe ctx de fora, então cancelamento do request, worker ou job continua valendo. Terceiro, nem todo status HTTP deve contar igual. Erros 500 e 429 indicam indisponibilidade ou sobrecarga da dependência. Erros 400 geralmente indicam problema no seu payload; eles devem ir para log e correção, mas não necessariamente abrir o circuito.

Fallback sem mentir para o usuário

Quando o breaker abre, você precisa decidir como degradar. Fallback bom é honesto. Ele não inventa aprovação de pagamento, não ignora regra de segurança e não marca uma operação crítica como concluída sem evidência. Em alguns sistemas, o fallback correto é negar temporariamente. Em outros, é aceitar a entrada, colocar em fila e informar que a análise será concluída depois.

func Handler(client *antifraude.Client, queue Queue) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
        defer cancel()

        result, err := client.Analyze(ctx, antifraude.Request{
            OrderID: "pedido-123",
            Amount:  12990,
        })
        if err != nil {
            if enqueueErr := queue.EnqueueReview(r.Context(), "pedido-123"); enqueueErr != nil {
                http.Error(w, "serviço temporariamente indisponível", http.StatusServiceUnavailable)
                return
            }
            w.WriteHeader(http.StatusAccepted)
            _, _ = w.Write([]byte("pedido recebido para análise assíncrona"))
            return
        }

        if !result.Approved {
            http.Error(w, "pedido em revisão", http.StatusAccepted)
            return
        }

        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte("pedido aprovado"))
    }
}

Esse exemplo transforma uma falha externa em processamento assíncrono. Para isso funcionar de verdade, a fila precisa ser idempotente, observável e segura contra duplicidade. Se você ainda não tem esse desenho, leia idempotência, retry e DLQ em Go antes de colocar fallback em produção.

Configuração que não vira chute

Os parâmetros do breaker devem refletir o comportamento normal da dependência. Um serviço que recebe dez chamadas por minuto não deve abrir com a mesma regra de um serviço que recebe dez mil chamadas por minuto. Comece conservador:

  • http.Client.Timeout: menor que o orçamento do request;
  • ReadyToTrip: exige volume mínimo antes de calcular taxa de falha;
  • Timeout do breaker: tempo suficiente para a dependência respirar;
  • MaxRequests: poucas tentativas no half-open para evitar enxurrada;
  • Interval: janela para limpar contadores quando o sistema está saudável.

Uma regra prática: se o endpoint é crítico e a dependência é instável, prefira falhar rápido com mensagem clara a segurar goroutine até estourar timeout. Se o endpoint é assíncrono, empurre para fila e responda 202 Accepted. Se é uma consulta opcional, mostre conteúdo principal sem o bloco dependente.

Métricas e logs obrigatórios

Circuit breaker sem observabilidade vira superstição. Você precisa saber quando ele abriu, por quê, por quanto tempo e qual foi o impacto no usuário. No mínimo, registre:

  • estado atual do breaker por dependência;
  • mudanças de estado com from e to;
  • total de chamadas permitidas, falhas rápidas e chamadas reais;
  • status HTTP e tipo de erro da dependência;
  • latência antes do erro;
  • número de fallbacks executados;
  • volume de operações mandadas para fila.

Com slog em Go, um evento de mudança de estado pode carregar campos estáveis:

logger.Warn("circuit breaker mudou de estado",
    "name", name,
    "from", from.String(),
    "to", to.String(),
)

Com OpenTelemetry em Go, adicione atributos como dependency.name, breaker.state e fallback.used. O ponto não é criar dashboard bonito. É responder rápido se o problema está no seu serviço, no fornecedor, na rede ou no excesso de retry.

Testando o comportamento

Teste de circuit breaker não deve depender de dormir trinta segundos em tempo real. Use configurações curtas no teste e um servidor httptest controlado. O objetivo é provar três coisas: abre depois de falhas suficientes, falha rápido quando está aberto e volta a permitir chamadas depois da janela.

func TestBreakerAbreDepoisDeFalhas(t *testing.T) {
    calls := 0
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        calls++
        http.Error(w, "down", http.StatusInternalServerError)
    }))
    defer srv.Close()

    c := New(srv.URL)

    for i := 0; i < 12; i++ {
        _, _ = c.Analyze(context.Background(), Request{OrderID: "x", Amount: 100})
    }

    before := calls
    _, err := c.Analyze(context.Background(), Request{OrderID: "x", Amount: 100})
    if err == nil {
        t.Fatal("esperava erro com breaker aberto")
    }
    if calls != before {
        t.Fatal("breaker aberto não deveria chamar o servidor")
    }
}

Em código real, ajuste o construtor para aceitar gobreaker.Settings no teste. Isso evita acoplar teste ao tempo de produção e permite simular janelas curtas. Também vale testar quais erros contam como falha. Um 400 Bad Request por payload inválido não deve ter o mesmo peso de 503 Service Unavailable.

Erros comuns em produção

O primeiro erro é combinar retry agressivo com breaker frouxo. Se cada requisição tenta três vezes e o breaker só abre depois de dezenas de falhas, você multiplica a carga antes de proteger o sistema. Retry precisa de backoff, jitter e limite. Em muitos fluxos de request síncrono, zero ou um retry já é suficiente.

O segundo é compartilhar um breaker único para dependências diferentes. API de pagamento, CEP, CRM e e-mail têm perfis de falha distintos. Um fornecedor ruim não deve abrir circuito de outro. Use um breaker por dependência e, em alguns casos, por operação crítica.

O terceiro é esquecer que cache também falha. Se Redis é otimização, falha de cache pode virar miss e seguir para banco. Se Redis é parte do contrato, como sessão ou lock, a degradação precisa ser mais cuidadosa. O tutorial de Go e Redis para cache ajuda a separar cache opcional de dependência crítica.

O quarto é esconder erro demais. Se o fallback sempre retorna uma resposta aparentemente normal, produto, suporte e SRE perdem o sinal. Prefira uma resposta parcial explícita, um 202 Accepted, uma mensagem de indisponibilidade temporária ou um status que permita ação do usuário.

O quinto é não conectar breaker a deploy. Durante incidentes, você pode precisar reduzir tráfego, desligar integração, trocar fornecedor ou ativar feature flag. Combine o padrão com feature flags em Go para ter controle operacional sem publicar código às pressas.

Checklist para APIs resilientes em Go

Antes de colocar um client protegido em produção, confira:

  • toda chamada externa tem context.Context e timeout explícito;
  • status 5xx e 429 contam como falha de dependência;
  • erros 4xx são tratados separadamente quando indicam bug de payload;
  • breaker é separado por fornecedor/operação importante;
  • fallback não viola segurança, pagamento ou privacidade;
  • métricas mostram estado, falhas rápidas e uso de fallback;
  • logs incluem dependência, operação, status e correlação de request;
  • testes cobrem abertura, falha rápida e recuperação;
  • retry, backoff e breaker não se multiplicam sem limite;
  • runbook explica o que fazer quando o circuito abre.

Circuit breaker não deixa uma arquitetura automaticamente resiliente. Ele só torna a falha explícita, limitada e mensurável. A maturidade vem de combinar timeout, limitação de concorrência, idempotência, fila, observabilidade e mensagens honestas para o usuário.

Se você está se preparando para vagas Go backend, esse é um ótimo tema para entrevista porque conecta código, infraestrutura e operação. Muitas vagas Go no Brasil pedem APIs, microserviços, cloud, observabilidade e sistemas distribuídos. Para comparar como resiliência aparece em outras stacks brasileiras, acompanhe também o portal eu.dev.br reúne vagas de tecnologia no Brasil, onde padrões como timeout, retry, fila e observabilidade aparecem independentemente da linguagem.