Em 2007, Michael Nygard publicou Release It! Design and Deploy Production-Ready Software, um livro que articulou em vocabulário operacional o que engenheiros experientes já faziam por intuição. Nygard cunhou os termos circuit breaker, bulkhead e steady state, e os apresentou como stability patterns. A premissa era simples: sistemas em produção falham, e a forma como reagem à falha define se a falha vira incidente local ou virá outage geral. O livro foi adotado por times de operação em massa, e em 2018 ganhou segunda edição com novos padrões e referências modernas.
Os três padrões deste conceito — retry, timeout, circuit
breaker — formam o núcleo do que se chama hoje de
resilience pattern: regras explícitas sobre como o
sistema se comporta quando uma dependência externa falha.
Cada um responde a um modo de falha distinto, e os três se
combinam em ordem específica para produzir comportamento
saudável. A tentação inicial de qualquer engenheiro é
escrever esses padrões à mão dentro do código de domínio —
for attempt in range(3): try: ... except: time.sleep(1).
Esse caminho é tangling clássico, e funciona até o sistema
crescer um pouco.
A solução madura é tratar resiliência como aspect: definir
policies nomeadas (read-idempotent,
write-idempotent, no-retry),
configurá-las uma vez no startup, e aplicá-las via decorator
ou pipeline em torno de cada chamada externa. O domínio chama
o cliente, e o cliente já carrega a policy. Polly v8 em .NET,
tenacity em Python, sony/gobreaker em Go — todos seguem essa
filosofia. Quando o time tem essas policies bem nomeadas,
revisões de código viram rápidas: "qual policy esse cliente
usa?" responde-se em segundos.
Este conceito articula os três padrões em profundidade, mostra a forma idiomática em três ecossistemas, e enuncia a heurística de composição que evita retry storm — uma das causas mais comuns de transformar degradação em outage.
Retry — a regra central é idempotência
Retry é tentar de novo após uma falha. A intuição é que muitas falhas são transitórias — pacote perdido, GC pause no servidor remoto, deploy em andamento. Refazer a chamada depois de um pequeno delay frequentemente resolve. O parágrafo seguinte é o mais importante deste conceito inteiro: retry só é seguro quando a operação é idempotente. Refazer uma operação que cria recurso novo sem garantia de idempotência produz duplicatas — pedido cobrado duas vezes, e-mail enviado duas vezes, registro inserido duas vezes.
Idempotência significa que executar a operação
n vezes produz o mesmo resultado de executá-la uma
vez. GET /pedidos/123 é idempotente por
construção — não muda estado. PUT /pedidos/123
com payload completo é idempotente — você está afirmando o
estado, não incrementando-o. POST /pedidos sem
idempotency key não é idempotente — cria pedido novo
em cada chamada. Para tornar POST idempotente, o
cliente envia um header Idempotency-Key com UUID;
o servidor mantém registro dos UUIDs vistos e retorna o
resultado cacheado se vir o mesmo.
A consequência prática é que retry policy precisa ser nomeada por intent. Policies típicas:
read-idempotent — para chamadas
GET ou POST de leitura. Pode retentar
livremente; tipicamente 3 tentativas com backoff exponencial.
write-idempotent — para
PUT/DELETE ou
POST+idempotency key. Pode retentar; igualmente
3 tentativas.
write-non-idempotent — para
POST sem idempotency key. Não pode
retentar com segurança. Policy aceita timeout, mas falha sem
retry. Quem chama precisa decidir o que fazer.
Algumas equipes refinam mais: separam por tipo de erro
(5xx tenta de novo, 4xx não, exceto
429 Too Many Requests que tenta com backoff
maior). É exagero em sistema simples, é higiene em sistema com
muitas dependências externas.
Backoff exponencial e jitter — a matemática do espaçamento
Retentar imediatamente após falha tem alta chance de falhar de novo — a causa transitória ainda está em curso. Espaçar tentativas dá tempo de o sistema remoto se recuperar. A pergunta é como espaçar.
Backoff exponencial: o intervalo dobra a cada tentativa. Tentativa 1 falha → espera 100ms; tentativa 2 falha → espera 200ms; tentativa 3 falha → espera 400ms. A intuição é que se o sistema está sob estresse, cada tentativa adicional contribui para o estresse, então recuar progressivamente é cooperativo.
O problema do backoff exponencial puro é o thundering herd. Imagine mil clientes concorrentes falhando ao mesmo tempo (porque o servidor remoto teve um soluço). Todos esperam exatamente 100ms; tentam de novo simultaneamente; se o servidor ainda está degradado, todos falham; todos esperam 200ms; tentam de novo simultaneamente. O servidor remoto recebe rajadas síncronas que prolongam a degradação. Por isso backoff com jitter:
# pseudocódigo de backoff com jitter
def delay(attempt: int) -> float:
cap = 30.0 # segundos
base = 0.1 # 100ms
raw = min(cap, base * (2 ** attempt))
return random.uniform(0, raw) # full jitter
Em 2015, Marc Brooker da AWS publicou o post "Exponential Backoff and Jitter" que formalizou três variantes (full jitter, equal jitter, decorrelated jitter) e argumentou que full jitter — sortear uniformemente entre 0 e o backoff calculado — minimiza tanto a contenção quanto o tempo total de recuperação. Em 2026, isso é consenso da indústria. Polly, tenacity, e bibliotecas Go modernas todos oferecem jitter como opção, geralmente ligado por padrão.
Timeout — limites em três níveis
Timeout é o oposto de "esperar para sempre". Toda chamada que depende de recurso externo precisa de timeout, e surpreendentemente muitos sistemas em produção não têm. A ausência de timeout é a forma mais sutil de instabilidade: tudo funciona até o servidor remoto ficar lento, e aí o sistema chamador fica preso esperando, threads/conexões ficam ocupadas, e a degradação se propaga.
Há três níveis de timeout que merecem ser configurados separadamente, e que muitos clientes confundem.
Connection timeout — quanto tempo esperar para estabelecer a conexão TCP/TLS. Geralmente curto (1-5s). Se a conexão não estabelece nesse tempo, a rede está em mau estado e não vai melhorar esperando mais.
Read timeout — quanto tempo esperar entre bytes recebidos. Diferente de connection timeout, pode ser maior (10-30s para operações pesadas). É o timeout que protege contra "servidor recebe a request mas demora a responder".
Total deadline — limite máximo da operação
inteira, do início ao fim, incluindo retries. Geralmente
vem do contexto do request (se a request HTTP tem deadline
de 5s, todas as chamadas downstream juntas precisam caber
em 5s). Em Go, isso é context.WithTimeout; em
.NET, CancellationToken com timer; em Python,
asyncio.wait_for. Esquecer total deadline produz
o caso onde "cada timeout individual é 5s, e três retries
podem somar 20s de espera", quebrando SLO.
Circuit breaker — o aspect que protege a dependência
Retry e timeout protegem o cliente de falha individual. Circuit breaker protege o servidor remoto da avalanche que retries causam. Nygard articulou o padrão em analogia ao disjuntor elétrico: quando muita corrente (carga) está passando, o circuito desarma para evitar incêndio; depois de um tempo, tenta fechar de novo cuidadosamente.
Circuit breaker tem três estados:
Closed — estado normal. Chamadas passam direto para a dependência. O breaker conta sucessos e falhas. Se a taxa de falha em uma janela passa de um limiar (50% em 30s, por exemplo, com mínimo de 10 chamadas), ele abre.
Open — estado de proteção. O breaker
rejeita chamadas imediatamente, sem nem tentar a
dependência. A rejeição é rápida (microssegundos) e o
cliente recebe erro específico (em Polly,
BrokenCircuitException; em gobreaker,
ErrOpenState). O breaker fica aberto por um
tempo configurado (30s, 1min) antes de testar.
Half-open — estado de teste. Depois do período aberto, o breaker permite uma (ou poucas) chamadas passarem. Se sucedem, ele fecha (volta a normal); se falham, ele reabre. É o mecanismo de recuperação cuidadosa.
// .NET — Polly v8 ResiliencePipeline
var pipeline = new ResiliencePipelineBuilder()
.AddTimeout(TimeSpan.FromSeconds(2)) // total deadline
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder()
.Handle<HttpRequestException>()
.HandleResult(static r => (int)r.StatusCode >= 500),
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(200),
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
MinimumThroughput = 10,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(30),
})
.Build();
// uso
var resp = await pipeline.ExecuteAsync(
async ct => await httpClient.GetAsync("/produtos", ct),
cancellationToken);
Polly v8 (lançada em outubro de 2023) trouxe a abstração
ResiliencePipeline que substituiu a API antiga
baseada em Policy.WrapAsync. A nova API é mais
legível, com strategies tipadas e composição clara.
Composição — a ordem importa
Os três padrões compõem em ordem específica. A regra é: retry está dentro de circuit breaker, e timeout envolve tudo. A justificativa é causal:
Timeout é a camada mais externa porque deve sempre cortar a operação se o tempo total estourou — independente de retries ou circuit breaker. Sem timeout externo, três retries com backoff podem somar 30 segundos enquanto o cliente esperava 5.
Retry vem dentro do timeout e fora ou dentro do circuit breaker dependendo da semântica desejada. Se retry está dentro do circuit breaker (o que Polly v8 chama de "ordem natural" e a maioria das libs faz), uma falha retentada conta como múltiplas falhas para o breaker, o que acelera a abertura — geralmente o que se quer. Se retry está fora do breaker (envolve o breaker), uma tentativa que vê breaker aberto pode retentar imediatamente em outro nó (load balancer), o que é útil em sistemas distribuídos com múltiplas réplicas.
Circuit breaker fica mais interno, perto da chamada à dependência. É ele quem decide se vai sequer tentar. Sem circuit breaker, retry sob falha contínua vira ataque a dependência já machucada — o famoso retry storm que transforma incidente em outage.
# Python — tenacity (retry) + circuitbreaker (breaker) + asyncio.timeout
import asyncio
from tenacity import retry, stop_after_attempt, wait_random_exponential
from circuitbreaker import circuit
import httpx
@circuit(failure_threshold=5, recovery_timeout=30, expected_exception=httpx.HTTPError)
@retry(stop=stop_after_attempt(3),
wait=wait_random_exponential(multiplier=0.2, max=10),
reraise=True)
async def chamar_fornecedor(cliente: httpx.AsyncClient, payload: dict) -> dict:
resp = await cliente.post("/cotacoes", json=payload)
resp.raise_for_status()
return resp.json()
async def cotar_com_timeout(cliente, payload):
try:
async with asyncio.timeout(2.0): # total deadline
return await chamar_fornecedor(cliente, payload)
except asyncio.TimeoutError:
raise UpstreamTimeoutError()
Em Python a composição é via decorators empilhados. A ordem
visual no código corresponde à ordem de "mais externo
primeiro" — @circuit envolve @retry
que envolve a função base. asyncio.timeout é
separado, fora dos decorators, porque atua no caller.
Bulkhead — o quarto padrão que merece menção
Apesar de não estar no título do conceito, bulkhead é parte do mesmo conjunto de Nygard e merece menção. Bulkhead vem da navegação — divisões internas de um navio que impedem que um vazamento em um compartimento afunde a embarcação inteira. Aplicado a software, bulkhead isola recursos: o pool de threads/conexões para chamar dependência X é separado do pool para chamar dependência Y. Se X está degradada e satura seu pool, Y continua funcionando porque tem recursos dedicados.
Polly v8 tem RateLimiter que cobre uma parte do
caso (limita concorrência por dependência). Em Go, é comum
configurar pools separados por host no
http.Transport. Em Python, é
httpx.AsyncClient dedicado por dependência. O
conceito é simples; a aplicação consistente é o desafio.
O mesmo serviço, três stacks de policy
Para fixar a equivalência, considere o cenário canônico: cliente HTTP que chama um fornecedor externo de estoque, com retry para 5xx, circuit breaker para proteger o fornecedor, e timeout total.
// startup
builder.Services.AddHttpClient<IFornecedorClient, FornecedorClient>(
c => c.BaseAddress = new Uri("https://fornecedor.example/"))
.AddResilienceHandler("fornecedor", b =>
{
b.AddTimeout(TimeSpan.FromSeconds(2));
b.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(static r => (int)r.StatusCode >= 500),
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
});
b.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
FailureRatio = 0.5,
MinimumThroughput = 10,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(30),
});
});
// uso (handler aplica policies transparentemente)
var resp = await _fornecedorClient.ConsultarEstoqueAsync(sku);
AddResilienceHandler integra Polly v8 com
HttpClientFactory. Cada chamada do client passa pela
pipeline de resiliência sem o código de domínio precisar
saber. Métricas de breaker são exportadas via OTel
automaticamente.
import asyncio
import httpx
from tenacity import (
retry, retry_if_exception_type, stop_after_attempt,
wait_random_exponential
)
from circuitbreaker import circuit
class FornecedorClient:
def __init__(self):
self._client = httpx.AsyncClient(
base_url="https://fornecedor.example/",
timeout=httpx.Timeout(connect=1.0, read=10.0, write=5.0, pool=5.0),
)
@circuit(failure_threshold=5, recovery_timeout=30,
expected_exception=(httpx.HTTPError,))
@retry(retry=retry_if_exception_type(httpx.HTTPError),
stop=stop_after_attempt(3),
wait=wait_random_exponential(multiplier=0.2, max=10),
reraise=True)
async def consultar_estoque(self, sku: str) -> Estoque:
async with asyncio.timeout(2.0):
r = await self._client.get(f"/estoques/{sku}")
r.raise_for_status()
return Estoque.parse_obj(r.json())
Empilhamento de decorators é Pythonic. Note os timeouts finos do httpx (connect/read/write/pool) — útil para ajuste fino, costumam ser ignorados.
package fornecedor
import (
"context"
"errors"
"net/http"
"time"
"github.com/hashicorp/go-retryablehttp"
"github.com/sony/gobreaker/v2"
)
type Client struct {
http *retryablehttp.Client
breaker *gobreaker.CircuitBreaker[*http.Response]
}
func NewClient() *Client {
rc := retryablehttp.NewClient()
rc.RetryMax = 3
rc.RetryWaitMin = 200 * time.Millisecond
rc.RetryWaitMax = 5 * time.Second // jitter aplicado internamente
cb := gobreaker.NewCircuitBreaker[*http.Response](gobreaker.Settings{
Name: "fornecedor",
MaxRequests: 1, // half-open: deixa passar 1
Interval: 30 * time.Second,
Timeout: 30 * time.Second,
ReadyToTrip: func(c gobreaker.Counts) bool {
failureRatio := float64(c.TotalFailures) / float64(c.Requests)
return c.Requests >= 10 && failureRatio >= 0.5
},
})
return &Client{http: rc, breaker: cb}
}
func (c *Client) ConsultarEstoque(ctx context.Context, sku string) (*Estoque, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := c.breaker.Execute(func() (*http.Response, error) {
req, _ := retryablehttp.NewRequestWithContext(ctx, "GET",
"https://fornecedor.example/estoques/"+sku, nil)
return c.http.Do(req)
})
if errors.Is(err, gobreaker.ErrOpenState) {
return nil, ErrFornecedorIndisponivel
}
if err != nil {
return nil, err
}
defer resp.Body.Close()
return parseEstoque(resp.Body)
}
Go obriga composição manual via tipos genéricos (gobreaker v2, 2024). Mais código, mas estado e fluxo explícitos — fácil de testar isoladamente.
Métricas de policy — sem isso, é fé
Cada policy precisa exportar métricas para que o time saiba como ela se comporta em produção. Sem métricas, o circuit breaker pode estar abrindo todo dia sem ninguém notar — ou pior, pode nunca estar abrindo apesar de a dependência estar degradada (limiares mal configurados). As métricas mínimas:
Retry: tentativas por chamada (counter), taxa de sucesso após retry (proporção). "Quantas chamadas precisaram de pelo menos uma tentativa adicional?".
Circuit breaker: estado (gauge: 0=closed, 1=open, 2=half-open), transições (counter), taxa de rejeição durante open. "Quanto tempo o breaker passou aberto na última hora?".
Timeout: latência de chamada (histogram), taxa de timeout (counter ou ratio). "Qual o P99 de latência antes de o timeout cortar?".
Polly v8 exporta métricas automaticamente via Activity/OTel.
Em Python, circuitbreaker expõe estado via
atributos do decorator; tenacity tem callbacks
(before_sleep, etc.) que viram métricas. Em Go,
gobreaker expõe estado via método; integração
com OTel é manual.
Retry storm — incidente que vira outage. Sequência clássica: servidor remoto fica lento (não morto, lento). Cliente A falha (timeout), retenta 3x. Cliente B faz o mesmo. Mil clientes fazem o mesmo. O servidor remoto, que só estava lento, agora recebe 4 mil requests por segundo em vez de mil — e morre de vez. A degradação inicial era recuperável; retry storm a transformou em outage. Defesas: circuit breaker (não tente quando o destino está claramente machucado), jitter (espalha as tentativas), retry com limite baixo (3 é geralmente o teto), e — crucial — retry policy nomeada por intenção, não global. Toda equipe sênior tem na memória pelo menos uma vez em que foi parte da causa de um retry storm; é experiência formativa cara.
Onde fica esse aspect na arquitetura
Resilience policy fica perto da dependência externa, em
uma das duas camadas: no cliente HTTP/gRPC (handler
pipeline), ou em decorator do repositório/service que usa o
cliente. Idealmente o domínio nem sabe que existe — chama o
cliente, recebe resultado ou exceção, e a exceção carrega
semântica útil ("dependency unavailable" vs "timeout" vs
"validation failed"). Isso facilita teste (mock do cliente
sem precisar mockar Polly) e legibilidade (handler não tem
try/except de retry).
O ponto sensível é a granularidade. Uma única policy "global para qualquer chamada externa" é tentadora e perigosa: ela aplica regras inadequadas para parte das chamadas. Mais saudável é policy por dependência: o cliente do fornecedor X tem uma policy, o do fornecedor Y tem outra. Em sistemas com 5–10 dependências, isso significa 5–10 policies configuradas no startup — investimento de uma tarde, retorno em sobrevivência.
Toda chamada externa que sai do seu sistema precisa responder a quatro perguntas, e a resposta precisa estar articulada — em código ou em documento — antes de ir para produção. Primeiro: qual é o timeout total dessa chamada? Segundo: ela é idempotente? Se sim, qual policy de retry? Se não, por quê? Terceiro: tem circuit breaker? Quais limiares? Quarto: como o sistema se comporta quando essa dependência está fora? Quem não consegue responder em revisão de PR ainda não terminou o trabalho.
Por que importa para a sua carreira
Resilience patterns separam quem sobreviveu a incidentes de quem ainda vai sobreviver. Em entrevistas de design para sistemas com SLAs, a pergunta "o que acontece quando a dependência X fica fora?" é direta — e a resposta forte cita timeout em três níveis, retry policy nomeada com idempotência, circuit breaker com limiares articulados, e bulkhead se apropriado. Em revisão de código, ver chamada a serviço externo sem timeout é alarme de senior; propor a refatoração antes do próximo deploy economiza incidente de 3 da manhã. Em pos-mortems, perguntar "tinha circuit breaker? Estava abrindo? Por quanto tempo?" é diagnóstico que líderes técnicos fazem para não voltar a tropeçar no mesmo padrão.
Como praticar
-
Pipeline de resiliência completo nas três
linguagens. Implemente em .NET (Polly v8), Python
(tenacity + circuitbreaker), e Go (gobreaker +
retryablehttp) o mesmo cliente: chama
https://httpbin.org/status/{500,503,200}randomicamente, com retry para 5xx, circuit breaker, e timeout total. Configure logs/métricas para visualizar tentativas, transições de breaker, e tempos. Teste induzindo falhas — verifique que o breaker abre, fica aberto, vai para half-open, e fecha. - Catálogo de policies para um sistema. Pegue um sistema seu (ou aberto) que use 3+ dependências externas. Para cada uma, articule e documente: tipo de operação (read-idempotent, write-idempotent, etc.), policy de retry (limites e backoff), parâmetros de circuit breaker, timeout total. Identifique pelo menos uma dependência onde a configuração atual está errada (ou ausente) e proponha em PR. Esse documento vira referência para futuros adições.
-
Simulação de retry storm. Suba um servidor
local que retorna
500por 30 segundos depois volta ao normal. Suba um cliente sem retry, depois com retry naïve sem jitter, depois com jitter, depois com circuit breaker. Em cada configuração, simule 100 clientes concorrentes e meça o tempo total que o servidor passa sobrecarregado e o tempo até a primeira recuperação. Esse exercício torna concreto, com número, o que retry storm é — e por que jitter+breaker minimizam.
Referências para aprofundar
- livro Release It! Design and Deploy Production-Ready Software (2ª ed.) — Michael T. Nygard (Pragmatic Bookshelf, 2018).
- livro Designing Data-Intensive Applications — Martin Kleppmann (O'Reilly, 2017).
- livro Site Reliability Engineering — Betsy Beyer et al. (O'Reilly, 2016).
- artigo Exponential Backoff and Jitter — Marc Brooker (AWS Architecture Blog, 2015).
- artigo Timeouts, retries, and backoff with jitter — Marc Brooker (Amazon Builders' Library).
- artigo The Circuit Breaker Pattern — Martin Fowler (martinfowler.com, 2014).
- docs Polly Documentation.
- docs tenacity Documentation.
- docs sony/gobreaker e hashicorp/go-retryablehttp.
- artigo Idempotency Keys: Stripe's API Approach — Brandur Leach (stripe.com/blog, 2017).
- artigo Bulkhead Pattern — Microsoft Azure Architecture Center.
- vídeo Resilience Engineering — Charity Majors (várias palestras 2019–2024).