Padrões de Resiliência
Quando um serviço downstream degrada ou falha, o comportamento ingênuo padrão — continuar enviando requisições e esperar uma resposta — produz, com toda a certeza, falhas em cascata. As threads do serviço que chama ficam bloqueadas, sua fila enche e a falha se propaga para cima, até clientes que nada tinham a ver com a dependência quebrada.
Padrões de resiliência são decisões de projeto que interrompem essa propagação. Eles não evitam falhas; eles limitam até onde uma falha se espalha pelo sistema1.
flowchart LR
client --> gateway
gateway -->|"Timeout\nCircuit Breaker\nBulkhead"| downstream:::slow
gateway -->|"falha rápida\nou fallback"| client:::ok
classDef slow fill:#f90,color:#000
classDef ok fill:#6c6,color:#fff Os padrões abaixo são complementares — sistemas em produção os usam em combinação, não isoladamente. Ao longo da página usamos um exemplo contínuo da loja: o serviço de pedidos chama o serviço de produtos (por meio de um cliente OpenFeign, como no Hands-on 1) para conferir os preços antes de salvar um pedido.
| Padrão | Pergunta que responde | Protege contra |
|---|---|---|
| Timeout | Quanto tempo estou disposto a esperar? | Chamadas travadas |
| Retry | Vale a pena tentar de novo? | Falhas transitórias |
| Circuit breaker | Devo sequer tentar? | Uma dependência que sabidamente está fora do ar |
| Fallback | O que respondo no lugar? | Perda total de uma funcionalidade |
| Bulkhead | Quanto da minha capacidade uma dependência pode ocupar? | Esgotamento de recursos se espalhando |
| Rate limiting | Quanto tráfego eu aceito? | Picos e clientes abusivos |
| Health checks | O tráfego deve chegar a esta instância? | Roteamento para instâncias quebradas |
Timeout
Um timeout é um prazo aplicado a uma chamada de saída. Se o serviço downstream não responder dentro do prazo, a chamada é abandonada e um erro é devolvido imediatamente a quem chamou.
Sem timeout, uma única dependência travada pode prender threads indefinidamente. Com timeout, o dano máximo que uma única chamada lenta pode causar fica limitado.
sequenceDiagram
participant C as Cliente
participant G as Gateway
participant P as Serviço de pagamento
C->>+G: POST /checkout
G->>+P: POST /charge
Note over P: processando...
Note over G: prazo excedido (500 ms)
G-->>-C: 503 Service Unavailable
P-->>-G: (resposta chega atrasada — descartada) Escolhendo os valores de timeout
- Baseado em percentis: defina o timeout na latência P99.9 do serviço downstream em condições normais. Qualquer coisa mais lenta que isso já é anormal.
- Consciente do orçamento: se a chamada de cima tem seu próprio timeout de 1 s, todas as chamadas downstream dentro daquela requisição precisam terminar, somadas, bem abaixo de 1 s.
- Distinto por dependência: uma consulta ao banco de dados, uma API de terceiros e um microsserviço interno têm perfis de latência muito diferentes. Um único valor global de timeout quase sempre está errado.
Normalmente há dois timeouts em uma chamada HTTP: o connect timeout (quanto esperar para abrir a conexão TCP) e o read timeout (quanto esperar pela resposta depois de conectado). Configure os dois.
Timeouts no cliente Feign da loja
# order-service application.yaml (Spring Cloud OpenFeign)
spring:
cloud:
openfeign:
client:
config:
product: # the @FeignClient name
connect-timeout: 500 # ms
read-timeout: 1000 # ms
Se o serviço de produtos levar mais de um segundo, o serviço de pedidos recebe uma exceção em vez de uma thread bloqueada e pode decidir o que fazer em seguida.
Timeouts são só o primeiro passo
Sozinho, um timeout apenas converte um travamento em um erro rápido. Combine-o com uma política de retry (abaixo) para falhas transitórias, e com um circuit breaker e um fallback para indisponibilidades mais longas.
Retry com backoff exponencial
Um retry tenta novamente uma requisição que falhou, após um período de espera. Retries são adequados para falhas transitórias — oscilações breves de rede, reinícios momentâneos de serviço ou esgotamento passageiro de recursos — em que a mesma requisição provavelmente terá sucesso numa tentativa seguinte.
sequenceDiagram
participant C as Cliente
participant S as Serviço
C->>+S: Requisição (tentativa 1)
S-->>-C: 503 Service Unavailable
Note over C: espera 1 s
C->>+S: Requisição (tentativa 2)
S-->>-C: 503 Service Unavailable
Note over C: espera 2 s
C->>+S: Requisição (tentativa 3)
S-->>-C: 200 OK ✓ Backoff exponencial
Repetir imediatamente após uma falha é perigoso: se muitos clientes fizerem retry ao mesmo tempo depois de um reinício do serviço, o pico resultante pode sobrecarregar o serviço que está se recuperando — uma manada estrondosa (thundering herd). O backoff exponencial espaça os retries:
| Tentativa | Fórmula | Espera (c = 1 s) |
|---|---|---|
| 1 | \(1 \cdot 2^0\) | 1 s |
| 2 | \(1 \cdot 2^1\) | 2 s |
| 3 | \(1 \cdot 2^2\) | 4 s |
| 4 | \(1 \cdot 2^3\) | 8 s |
| 5 | \(1 \cdot 2^4\), limitado | 16 s (= max_wait de 16 s) |
| 6+ | limitado ao máximo | 16 s |
Jitter
Mesmo com backoff exponencial, clientes que falharam todos no mesmo instante farão retry nos mesmos instantes — uma onda sincronizada de retries. Adicionar um jitter aleatório quebra essa sincronização:
A análise de Marc Brooker na AWS2 comparou diversas variantes e recomenda o full jitter, que torna aleatória a espera inteira, em vez de somar uma pequena quantidade aleatória:
Com 1.000 clientes falhando no mesmo instante, o full jitter espalha os retries uniformemente pelo intervalo, em vez de enviar 1.000 requisições exatamente em t = 1 s, 2 s, 4 s.
O que repetir — e o que não repetir
| Resposta | Retry? | Por quê |
|---|---|---|
| Conexão recusada, conexão resetada, timeout | Sim | Falha transitória típica |
503 Service Unavailable, 429 Too Many Requests | Sim, com backoff (respeite o Retry-After, se houver) | O servidor está pedindo que você volte mais tarde |
500 Internal Server Error | Talvez, uma vez | Muitas vezes é um bug que vai falhar de novo |
400, 401, 403, 404, 422 | Não | A própria requisição está errada; repetir não resolve |
Amplificação de retries
Retries multiplicam a carga exatamente quando o sistema está mais fraco. Se o gateway, o serviço de pedidos e o cliente de produtos fizerem 3 tentativas cada, um clique do usuário pode virar 3 × 3 × 3 = 27 chamadas a um serviço de produtos que já está com dificuldades. Faça retry em uma camada apenas (normalmente a mais próxima da dependência que falha), limite o número de tentativas e pare de repetir quando o circuit breaker estiver aberto.
Não faça retry de operações não idempotentes
Repetir um POST /orders que cria um novo pedido pode gerar duplicatas. Só faça retry de operações idempotentes (GET, PUT, DELETE) ou explicitamente projetadas para serem repetidas com segurança (chaves de idempotência, tokens de deduplicação).
Uma operação é idempotente quando executá-la duas vezes tem o mesmo efeito que executá-la uma vez. A forma comum de tornar um POST seguro para retry é uma chave de idempotência (idempotency key): o cliente gera um ID único (por exemplo, um UUID) por operação lógica e o envia em um cabeçalho; o servidor guarda as chaves que já processou e, diante de uma chave repetida, devolve o resultado armazenado em vez de criar um segundo pedido.
sequenceDiagram
participant C as Cliente
participant O as Serviço de pedidos
participant DB as Postgres
C->>O: POST /orders (Idempotency-Key: k-42)
O->>DB: chave k-42 já vista? não → insere pedido + chave
O--xC: 201 Created (resposta perdida)
C->>O: retry POST /orders (Idempotency-Key: k-42)
O->>DB: chave k-42 já vista? sim
O-->>C: 201 Created (mesmo pedido, sem duplicata) Circuit Breaker
O circuit breaker3 recebe esse nome por analogia com o disjuntor elétrico: quando a carga ultrapassa os limites seguros, o disjuntor desarma e interrompe o circuito. Em software, quando a taxa de falhas de um serviço downstream passa de um limite, o circuit breaker abre e rejeita imediatamente as chamadas para aquele serviço — sem sequer tentar a chamada de rede.
Isso evita que o serviço que chama desperdice threads e conexões com uma dependência que sabidamente está indisponível.
stateDiagram-v2
[*] --> Closed
Closed --> Open : taxa de falhas passa do limite\n(ex.: > 50% de erros nas últimas 10 chamadas)
Open --> HalfOpen : tempo de espera expira\n(ex.: 30 segundos)
HalfOpen --> Closed : requisição de teste tem sucesso
HalfOpen --> Open : requisição de teste falha Closed (fechado — operação normal) : As requisições passam. O breaker conta sucessos e falhas. Se a taxa de falhas ultrapassar o limite configurado, o breaker desarma para Open.
Open (aberto — falhando rápido) : Todas as requisições são rejeitadas imediatamente com um erro — nenhuma chamada de rede é feita. Isso protege os recursos de quem chama e dá tempo para o serviço downstream se recuperar. Após um tempo de espera configurado, o breaker passa para Half-Open.
Half-Open (semiaberto — testando a recuperação) : Um número limitado de requisições de teste é liberado. Se tiverem sucesso, o serviço downstream se recuperou e o breaker fecha. Se falharem, o breaker volta para Open.
Parâmetros de configuração
| Parâmetro | Valor típico | Efeito |
|---|---|---|
| Limite de falhas | 50% das últimas 10–20 chamadas | Sensibilidade a falhas |
| Tempo de espera | 30–60 segundos | Quanto tempo o serviço tem para se recuperar |
| Nº de testes em Half-Open | 3–5 requisições | Com que cautela testar a recuperação |
| Limite de chamada lenta | 2× o P99 normal | Tratar chamadas lentas como falhas |
Spring Boot: a biblioteca Resilience4j4 implementa todos esses padrões com as anotações @CircuitBreaker, @Retry, @Bulkhead, @RateLimiter e @TimeLimiter (dependência io.github.resilience4j:resilience4j-spring-boot3 mais spring-boot-starter-aop).
Circuit breaker e retry em torno da chamada ao serviço de produtos
@CircuitBreaker(name = "product", fallbackMethod = "priceFallback")
@Retry(name = "product")
public ProductOut findProduct(String id) {
return productController.findById(id).getBody(); // Feign call
}
// Same signature + the exception. Called when the call fails
// or when the breaker is open (CallNotPermittedException).
private ProductOut priceFallback(String id, Throwable t) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "catalogue unavailable, try again later");
}
resilience4j:
circuitbreaker:
instances:
product:
sliding-window-size: 10 # last 10 calls
failure-rate-threshold: 50 # % → Open
slow-call-duration-threshold: 2s # slow = failure
wait-duration-in-open-state: 30s # Open → Half-Open
permitted-number-of-calls-in-half-open-state: 3
retry:
instances:
product:
max-attempts: 3 # 1 call + 2 retries
wait-duration: 500ms
enable-exponential-backoff: true # 500 ms, 1 s, ...
exponential-backoff-multiplier: 2
O estado do breaker é exportado como métrica (resilience4j_circuitbreaker_state) pelo Actuator, então você pode exibi-lo em um gráfico no Grafana (Hands-on 6).
A simulação abaixo mostra o circuit breaker percorrendo os três estados à medida que a taxa de falhas muda:
Fallback e degradação graciosa
Falhar rápido é melhor do que travar, mas um erro continua sendo um erro. Um fallback é o que quem chama faz no lugar quando uma chamada falha ou o breaker está aberto. O objetivo é a degradação graciosa: perder uma funcionalidade, não a página inteira.
| Dependência fora do ar | Fallback possível na loja |
|---|---|
| Serviço de recomendações | Mostrar a lista de "mais vendidos" em cache no Redis (Hands-on 8) |
| Serviço de produtos, para leituras | Servir os últimos dados de produto em cache, marcados como possivelmente desatualizados |
| Serviço de notificações | Publicar o pedido de e-mail no Kafka e enviá-lo depois (Arquitetura Orientada a Eventos) |
| Serviço de pagamento | Não há fallback seguro: responder 503 rapidamente com uma mensagem clara — nunca fingir que o pagamento deu certo |
Um fallback precisa ser seguro e simples
Um fallback que chama outro serviço remoto também pode falhar (e precisa da própria proteção). E algumas operações — cobrar um cartão, reservar estoque — não podem ser simuladas: falhar de forma clara é o comportamento degradado correto.
Bulkhead
O padrão bulkhead — nome que vem dos compartimentos estanques do casco de um navio — isola recursos por dependência. Em vez de compartilhar um único pool de threads entre todas as chamadas downstream, cada dependência ganha seu próprio pool.
flowchart TD
gateway --> poolA["Pool de threads A\n(máx. 10 threads)"]
gateway --> poolB["Pool de threads B\n(máx. 10 threads)"]
poolA --> payment["Serviço de pagamento\n(degradado ⚠️)"]:::slow
poolB --> inventory["Serviço de estoque\n(saudável ✓)"]:::ok
classDef slow fill:#f90,color:#000
classDef ok fill:#6c6,color:#fff Se o serviço de pagamento degradar e ocupar as 10 threads do Pool A, o Pool B não é afetado em nada. O serviço de estoque continua atendendo requisições a toda velocidade. Sem bulkheads, uma única dependência lenta satura o pool compartilhado e degrada todas as outras chamadas por igual.
O custo dos bulkheads é o consumo extra de recursos: dez pools separados de 10 threads cada usam mais memória do que um pool de 100 threads. Para dependências do caminho crítico, esse trade-off quase sempre compensa.
O Resilience4j oferece dois tipos de bulkhead: o bulkhead de semáforo (max-concurrent-calls, limita quantas threads podem estar dentro da chamada ao mesmo tempo — barato, é o padrão) e o bulkhead de pool de threads (a chamada roda em um pool dedicado). A mesma ideia vale fora da JVM: pools de conexões de banco separados por workload, ou deployments separados no Kubernetes para tráfego crítico e não crítico.
Rate limiting
O rate limiting (limitação de taxa) restringe o número de requisições que um cliente (ou o sistema) pode fazer dentro de uma janela de tempo, protegendo os serviços de picos de tráfego e de clientes abusivos.
Algoritmo token bucket
O token bucket (balde de fichas) é o algoritmo de rate limiting mais comum. Ele modela um balde que se enche de fichas (tokens) a uma taxa constante; cada requisição consome uma ficha; requisições que chegam com o balde vazio são rejeitadas ou enfileiradas.
flowchart LR
refill["Fonte de tokens\n(reposição: 100 req/s)"] -->|adiciona tokens| bucket["Token Bucket\n(capacidade: 200)"]
request["Requisição\nde entrada"] -->|consome 1 token| bucket
bucket -->|há tokens| allowed["✓ Permitida"]
bucket -->|balde vazio| rejected["✗ 429 Too Many Requests"] | Propriedade | Efeito |
|---|---|
| Taxa de reposição | Vazão máxima em regime permanente |
| Capacidade do balde (burst) | Quanto tráfego instantâneo acima da taxa é tolerado |
Uma capacidade de balde maior que a taxa de reposição permite rajadas curtas e ainda assim impõe uma média de longo prazo. O filtro RequestRateLimiter do Spring Cloud Gateway (apoiado no Redis) e o AWS API Gateway usam token bucket. O limit_req do Nginx usa o leaky bucket (balde furado), muito parecido: as requisições escoam a uma taxa fixa e o parâmetro burst define quantas podem esperar na fila.
Rate limiting na borda da loja (Nginx)
# 10 requests/s per client IP, bursts of up to 20 queued requests
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location / {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://gateway;
}
}
Isso se encaixa na frente das réplicas do gateway do Hands-on 7. Limites por usuário ou por chave de API (em vez de por IP) costumam ser aplicados no gateway, onde a identidade autenticada é conhecida.
Health checks
Endpoints de health check permitem que orquestradores (Kubernetes, Docker Compose, balanceadores de carga) detectem instâncias com falha e parem de rotear tráfego para elas antes que os clientes percebam.
Dois tipos distintos servem a propósitos diferentes:
Liveness : O processo está vivo? Retorna 200 OK se a aplicação está rodando e não está em deadlock. Se uma verificação de liveness falha, o orquestrador reinicia o container.
Readiness : O serviço está pronto para receber tráfego? Retorna 200 OK só se a instância terminou de iniciar e consegue fazer trabalho útil (por exemplo, seu próprio banco de dados está acessível). Se uma verificação de readiness falha, o orquestrador remove a instância do pool do balanceador de carga sem reiniciá-la.
Startup (Kubernetes) : A aplicação terminou de iniciar? As verificações de liveness e readiness só rodam depois que ela tem sucesso, para que uma JVM de inicialização lenta não seja morta durante o boot.
flowchart LR
lb["Balanceador de carga"] -->|"GET /actuator/health/readiness"| svc["Instância do serviço"]
svc -->|200 OK| lb
lb -->|roteia tráfego| svc
svc2["Instância do serviço\n(conexão com o banco perdida)"]
lb -->|"GET /actuator/health/readiness"| svc2
svc2 -->|503 DOWN| lb
lb -->|removida do pool| svc2:::failed
classDef failed fill:#f66,color:#fff O Spring Boot Actuator expõe ambos em /actuator/health/liveness e /actuator/health/readiness. Eles são habilitados automaticamente quando a aplicação detecta que roda no Kubernetes; em outros ambientes (por exemplo, Docker Compose), defina management.endpoint.health.probes.enabled=true. Por padrão, eles refletem apenas o estado interno da aplicação; inclua uma dependência na readiness explicitamente, por exemplo management.endpoint.health.group.readiness.include=readinessState,db.
Probes do serviço de pedidos no Kubernetes
# inside the container spec of the Deployment
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
failureThreshold: 30 # up to 30 × 5 s = 150 s to start
periodSeconds: 5
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
Veja o Hands-on 5 para os Deployments aos quais essas probes pertencem.
Nunca coloque dependências externas na verificação de liveness
Se a liveness verifica o banco de dados e o banco oscila, o Kubernetes reinicia todas as réplicas ao mesmo tempo — transformando um soluço de 10 segundos no banco em uma indisponibilidade total enquanto todas as JVMs sobem de novo. A liveness deve responder apenas "este processo está travado?". Tome cuidado até com a readiness: se todas as réplicas dependem do mesmo banco, todas ficam não prontas juntas e o serviço desaparece, em vez de retornar erros claros.
Defesa em profundidade
Esses padrões são combinados em camadas, não escolhidos isoladamente. Uma cadeia de chamadas de nível de produção combina todos eles:
flowchart LR
client --> rl["Rate Limiter\n(protege o serviço)"]
rl --> cb["Circuit Breaker\n(falha rápido em deps abertas)"]
cb --> bh["Bulkhead\n(isola o pool de threads)"]
bh --> to["Timeout\n(limita a espera)"]
to --> retry["Retry + Backoff\n(trata transitórias)"]
retry --> downstream[Serviço downstream] Cada camada trata um cenário de falha diferente:
| Camada | O que trata |
|---|---|
| Rate limiter | Picos de tráfego, clientes abusivos |
| Circuit breaker | Dependências que sabidamente estão falhando |
| Bulkhead | Dependências lentas contaminando outras chamadas |
| Timeout | Espera sem limite |
| Retry + backoff | Erros transitórios |
A ordem é uma decisão de projeto
O diagrama mostra uma organização conceitual comum. As bibliotecas fazem suas próprias escolhas: na integração do Resilience4j com o Spring Boot, a ordem padrão dos aspectos é Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( call ) ) ) ) ) — o retry é a camada mais externa, então cada tentativa é contabilizada pelo circuit breaker e um breaker aberto faz as tentativas restantes falharem imediatamente. Qualquer que seja a ordem, verifique que os retries não contornam o breaker e que a soma de timeouts × tentativas cabe no prazo de quem chama.
Conexões
- Hands-on 1 — os clientes Feign que precisam de timeouts e breakers.
- Hands-on 7 — o Nginx na frente do gateway, onde fica o rate limiting de borda.
- Hands-on 9 — retries e dead letter queues para consumidores assíncronos.
- SLI / SLO / SLA — o orçamento que esses padrões protegem.
Pontos-chave
- Toda chamada remota precisa de um timeout (connect + read), escolhido por dependência e dentro do orçamento de quem chama.
- Faça retry só de falhas transitórias e operações idempotentes, com backoff exponencial e full jitter, em uma única camada; use chaves de idempotência para tornar um
POSTseguro. - Um circuit breaker (Closed → Open → Half-Open) falha rápido diante de uma dependência que sabidamente está fora do ar; combine-o com um fallback seguro.
- Bulkheads impedem que uma dependência lenta consuma todas as threads ou conexões; o rate limiting (token / leaky bucket) protege o serviço do excesso de tráfego.
- Liveness reinicia, readiness remove do balanceador de carga; nunca verifique dependências externas na liveness.
- Os padrões são combinados em camadas; sua ordem e a soma dos timeouts precisam ser projetadas e depois verificadas com experimentos de caos.
-
NYGARD, M. Release It! Design and Deploy Production-Ready Software, 2nd ed. Pragmatic Programmers, 2018. ↩
-
BROOKER, M. Exponential Backoff And Jitter. AWS Architecture Blog, 2015. ↩
-
FOWLER, M. CircuitBreaker. martinfowler.com, 2014. ↩