Serviços falham de formas pouco educadas. Uma API externa fica lenta antes de cair, um banco começa a responder timeout, um provedor de pagamento devolve 500 por alguns minutos, um endpoint interno entra em deploy e uma fila tenta processar tudo de novo ao mesmo tempo. Sem uma política explícita, o código cliente normalmente faz a pior combinação: espera demais, tenta de novo rápido demais, prende workers, aumenta fila, piora a carga no serviço que já estava ruim e ainda deixa logs difíceis de investigar.
O padrão circuit breaker existe para quebrar esse ciclo. Em vez de chamar uma dependência doente indefinidamente, o cliente observa falhas recentes, abre o circuito por uma janela curta e falha rápido. Depois testa a dependência com poucas chamadas controladas. Se ela voltou, fecha o circuito; se continua ruim, mantém a proteção.
Zig combina bem com esse padrão porque força a modelagem explícita: estados pequenos, erros nomeados, relógio injetável, limites visíveis e testes sem mágica. Este guia mostra como desenhar um circuit breaker em Zig para clientes HTTP, workers e integrações internas, junto com timeout, retry com backoff, fallback e métricas. Ele complementa os guias de servidor HTTP em Zig para produção, filas e workers em background, tratamento de erros em Zig, observabilidade e OpenTelemetry em Zig.
O problema não é só erro 500
Um erro claro é fácil: a dependência respondeu 500, você registra e decide se tenta de novo. O problema real é a zona cinzenta:
- chamadas que levam 30 segundos e ocupam todos os workers;
- respostas
429que pedem desaceleração; 503temporário durante deploy;- conexões que abrem, mas não entregam body;
- DNS intermitente;
- pool de conexão esgotado;
- payload inválido vindo de um serviço que deveria respeitar contrato;
- erro de autenticação que nunca vai melhorar com retry.
Circuit breaker não substitui timeout, retry, fila ou idempotência. Ele coordena essas peças. Timeout limita uma chamada. Retry decide se uma falha merece nova tentativa. Backoff espaça tentativas. Circuit breaker decide se vale iniciar a chamada agora.
Estados do circuito
O desenho clássico tem três estados:
| Estado | Comportamento | Quando usar |
|---|---|---|
closed | chamadas passam normalmente | dependência saudável |
open | chamadas falham rápido | falhas passaram do limite |
half_open | poucas chamadas de teste passam | janela de recuperação venceu |
Em closed, o breaker conta falhas recentes. Quando o limite é atingido, abre. Em open, ele não chama a dependência e retorna um erro previsível, como CircuitOpen. Depois de cooldown_ms, passa para half_open. Nessa fase, uma ou poucas chamadas testam o serviço. Se der certo, volta para closed. Se falhar, abre de novo.
A regra importante: circuit breaker não deve esconder erro permanente. Se a API retorna 401 porque o token está errado, abrir circuito só atrasa o diagnóstico. Classifique erros.
Configuração mínima
Comece com uma configuração pequena e revisável:
const BreakerConfig = struct {
failure_threshold: u32 = 5,
success_threshold: u32 = 2,
cooldown_ms: u64 = 30_000,
request_timeout_ms: u64 = 2_000,
};
Esses valores não são universais. Um endpoint de checkout pode exigir timeout menor e fallback conservador. Uma sincronização em background pode esperar mais, mas com retry espaçado. Um serviço interno de baixa latência talvez abra circuito depois de três falhas. O segredo é manter a regra perto da integração, não escondida em constante global sem contexto.
Modelo em Zig
A implementação abaixo é deliberadamente pequena. Ela não sabe nada sobre HTTP; só decide se uma chamada pode acontecer e registra resultado.
const std = @import("std");
const CircuitState = enum {
closed,
open,
half_open,
};
const BreakerError = error{
CircuitOpen,
};
const CircuitBreaker = struct {
config: BreakerConfig,
state: CircuitState = .closed,
failures: u32 = 0,
successes: u32 = 0,
opened_at_ms: u64 = 0,
pub fn init(config: BreakerConfig) CircuitBreaker {
return .{ .config = config };
}
pub fn beforeCall(self: *CircuitBreaker, now_ms: u64) BreakerError!void {
switch (self.state) {
.closed => return,
.open => {
const elapsed = now_ms -| self.opened_at_ms;
if (elapsed >= self.config.cooldown_ms) {
self.state = .half_open;
self.successes = 0;
return;
}
return BreakerError.CircuitOpen;
},
.half_open => return,
}
}
pub fn recordSuccess(self: *CircuitBreaker) void {
switch (self.state) {
.closed => self.failures = 0,
.half_open => {
self.successes += 1;
if (self.successes >= self.config.success_threshold) {
self.state = .closed;
self.failures = 0;
self.successes = 0;
}
},
.open => {},
}
}
pub fn recordFailure(self: *CircuitBreaker, now_ms: u64) void {
switch (self.state) {
.closed => {
self.failures += 1;
if (self.failures >= self.config.failure_threshold) {
self.open(now_ms);
}
},
.half_open => self.open(now_ms),
.open => {},
}
}
fn open(self: *CircuitBreaker, now_ms: u64) void {
self.state = .open;
self.opened_at_ms = now_ms;
self.successes = 0;
}
};
Esse código evita alocação e é fácil de testar. Para produção, você provavelmente adicionaria janela móvel, mutex se houver múltiplas threads, limite de chamadas simultâneas em half_open e métricas. Mas a estrutura principal já aparece: a chamada precisa passar por beforeCall, e o resultado precisa voltar por recordSuccess ou recordFailure.
Classifique erros antes de contar falha
Nem todo erro deve abrir circuito. Em Zig, vale modelar a decisão como função explícita:
const UpstreamError = error{
Timeout,
ConnectionReset,
TooManyRequests,
ServiceUnavailable,
InvalidCredentials,
InvalidRequest,
BadPayload,
};
fn shouldCountFailure(err: UpstreamError) bool {
return switch (err) {
error.Timeout,
error.ConnectionReset,
error.TooManyRequests,
error.ServiceUnavailable => true,
error.InvalidCredentials,
error.InvalidRequest,
error.BadPayload => false,
};
}
Essa separação evita retry inútil. InvalidCredentials deve disparar alerta e correção de configuração. InvalidRequest deve virar bug ou validação de contrato. Timeout e ServiceUnavailable podem ser transitórios e contam para o breaker.
Integração com cliente HTTP
Em um cliente real, o fluxo fica assim:
pub fn fetchInvoice(
breaker: *CircuitBreaker,
client: *HttpClient,
id: []const u8,
now_ms: u64,
) !Invoice {
try breaker.beforeCall(now_ms);
const result = client.getInvoice(id, .{
.timeout_ms = breaker.config.request_timeout_ms,
});
if (result) |invoice| {
breaker.recordSuccess();
return invoice;
} else |err| {
if (shouldCountFailure(err)) {
breaker.recordFailure(now_ms);
}
return err;
}
}
O detalhe operacional é o timeout. Circuit breaker sem timeout é fraco, porque a chamada ainda pode prender o worker por tempo demais antes de registrar falha. Timeout deve existir no cliente HTTP, no pool, no proxy e no job que está chamando a dependência.
Para integrações com std.http.Client, centralize headers, tamanho máximo de resposta, timeout e mapeamento de status em uma camada pequena. Não espalhe client.fetch cru por handlers e workers. O artigo de OpenAPI em Zig aprofunda essa ideia de contrato explícito.
Retry com backoff e jitter
Retry deve acontecer antes ou depois do circuit breaker? Depende. A regra comum é: o breaker autoriza uma unidade lógica de chamada; dentro dela, você pode fazer poucas tentativas com backoff curto para erro transitório. Se todas falharem, registra uma falha no breaker.
Não conte cada tentativa individual como falha se isso abrir o circuito rápido demais por uma única requisição. Conte a operação final.
fn backoffMs(attempt: u32) u64 {
const base: u64 = 100;
const max: u64 = 2_000;
const shifted = base << @min(attempt, 4);
return @min(shifted, max);
}
Em produção, adicione jitter para evitar manada: várias réplicas acordando no mesmo milissegundo depois de uma falha. Para jobs de background, combine isso com a estratégia do guia de filas e workers: idempotência, teto de tentativas e dead letter quando a falha deixa de ser transitória.
Fallback: quando responder sem chamar
Fallback é útil, mas perigoso. Ele deve ser honesto sobre frescor e escopo. Exemplos aceitáveis:
- devolver preço em cache com aviso de atualização pendente;
- usar configuração local quando o serviço de flags caiu;
- enfileirar operação para processamento posterior;
- retornar página degradada sem recomendações personalizadas;
- exibir resultado anterior com timestamp.
Exemplos ruins:
- confirmar pagamento sem consultar provedor;
- conceder acesso premium por fallback inseguro;
- esconder erro de autenticação como instabilidade temporária;
- inventar dado financeiro, médico ou legal.
Quando a integração tem impacto em dinheiro, permissão, segurança ou auditoria, prefira falhar fechado ou colocar em fila idempotente. Para comparar escolhas de resiliência em outro ecossistema backend, o Golang Brasil tem bons materiais sobre serviços e concorrência; Zig dá mais controle, mas também exige que você escreva a política operacional explicitamente.
Observabilidade obrigatória
Um circuit breaker sem métrica vira superstição. Registre pelo menos:
- estado atual por dependência;
- transições
closed -> open,open -> half_open,half_open -> closed; - chamadas bloqueadas por
CircuitOpen; - falhas por tipo (
timeout,503,connection_reset); - latência de chamadas bem-sucedidas;
- uso de fallback;
- tentativas de retry e tempo de backoff.
Logs devem carregar nome da dependência, rota/operação, estado do breaker e tipo de erro. Não registre segredo, token, payload sensível ou corpo completo de resposta. Se você usa traces, marque a chamada externa como span próprio e adicione atributo de resultado. O guia de Zig e OpenTelemetry mostra como pensar essa camada sem transformar logs em lixo.
Testes que importam
Teste o breaker com relógio controlado. Não use sleep em teste unitário.
test "abre circuito depois do limite de falhas" {
var breaker = CircuitBreaker.init(.{
.failure_threshold = 2,
.cooldown_ms = 1_000,
});
try breaker.beforeCall(0);
breaker.recordFailure(0);
try breaker.beforeCall(10);
breaker.recordFailure(10);
try std.testing.expectEqual(CircuitState.open, breaker.state);
try std.testing.expectError(error.CircuitOpen, breaker.beforeCall(20));
try breaker.beforeCall(1_020);
try std.testing.expectEqual(CircuitState.half_open, breaker.state);
}
Depois teste classificação de erro, sucesso em half_open, falha em half_open, cooldown com subtração saturada e concorrência se a struct for compartilhada entre threads. Para cliente HTTP, use servidor fake ou adapter injetado que devolve sequência previsível: timeout, 503, sucesso.
Onde guardar o breaker
O breaker deve representar uma dependência ou operação, não o programa inteiro. Exemplos:
payment_api.charge
payment_api.refund
crm_api.create_lead
search_api.query
email_provider.send
Misturar tudo em um único breaker global faz uma rota ruim derrubar chamadas saudáveis. Separar demais também cria ruído. Uma boa granularidade é por dependência e tipo de operação quando o risco é diferente.
Em aplicação single-threaded, uma struct em memória pode bastar. Em múltiplas threads, proteja com mutex ou use uma fila/event loop que centraliza chamadas. Em múltiplas réplicas, não tente sincronizar estado do breaker no primeiro dia. Circuit breaker local por processo já reduz dano. Só leve estado para Redis ou outro coordenador se houver motivo forte e observabilidade para provar.
Checklist de produção
Antes de ligar em tráfego real:
- toda chamada externa tem timeout explícito;
- erros permanentes não contam para abrir circuito;
- retry tem limite, backoff e jitter;
- operações repetidas são idempotentes quando possível;
- fallback é seguro e documenta frescor;
- métricas mostram estado e transições;
- logs não vazam segredo ou payload sensível;
-
half_openlimita chamadas de teste; - testes usam relógio controlado;
- alertas diferenciam dependência lenta, circuito aberto e erro de contrato.
Circuit breaker não é uma biblioteca mágica; é uma decisão operacional codificada. Em Zig, isso é vantagem. Você consegue ver cada limite, cada transição e cada erro que merece retry. O resultado é menos dramático que “resiliência distribuída” vendida em slide, mas mais útil: um cliente pequeno, testável e previsível que protege seu serviço quando o mundo externo para de colaborar.