Lab 36 — Retry, backoff, jitter e circuit breaker no .NET
O problema, e a empresa que o tem
Este laboratório continua de onde o L22 parou. A Cadência já resolveu perda de pedido sob pico (fila SQS como buffer) e cobrança duplicada por reentrega (chave de idempotência no DynamoDB). O que sobrou foi um incômodo menor, que virou um incidente maior.
O incômodo: quando o gateway de pagamento recusa uma cobrança, a mensagem só volta para a fila depois de 60 segundos — o visibility_timeout configurado no L22. Para o cliente que está esperando saber se o pedido foi aceito, 60 segundos parece travado, mesmo a Cadência sabendo que é só a fila fazendo o trabalho dela.
Um engenheiro resolveu isso com a ferramenta mais curta: um laço de três tentativas imediatas dentro de CobrarCartaoAsync, sem esperar nada entre elas. Em ambiente de desenvolvimento funcionou — o parceiro simulado local nunca falha. Na campanha seguinte, com tráfego dez vezes o normal, o gateway de pagamento começou a degradar sob a própria carga, e o retry ingênuo transformou uma degradação de alguns minutos numa indisponibilidade completa do checkout por vinte e dois minutos.
O padrão tem nome: retry storm. Não é bug de lógica — a cada linha do código faz exatamente o que foi escrita para fazer. É o EFEITO AGREGADO de centenas de invocações concorrentes, cada uma "só tentando de novo", que multiplica carga exatamente no instante em que o downstream mais precisa de menos.
O que este laboratório NÃO é
Não é sobre CHAMAR o parceiro de pagamento real em teste de carga — isso violaria o limite de taxa contratual dele e é o tipo de coisa que gera ligação da equipe de parceria. O laboratório usa um simulador implantado na própria conta da Cadência, com comportamento calibrado para reproduzir o que foi observado do parceiro real. A distinção entre "cliente resiliente" e "parceiro confiável" importa: aqui você só controla o primeiro.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando na seção de implantação, não com a sensação de ter entendido.
- Reproduzir um retry storm real, com número de requisições multiplicadas e queda de throughput medida.
- Explicar por que backoff exponencial sozinho ainda sincroniza clientes que falharam juntos.
- Configurar jitter decorrelacionado e citar a fórmula canônica que o justifica.
- Distinguir o que retry resolve do que circuit breaker resolve, com uma frase para cada.
- Calcular o orçamento de tempo do pipeline de retry e mantê-lo abaixo do timeout da função.
- Nomear os três estados do disjuntor e o que decide a transição entre eles.
- Provar, com contagem de chamadas de rede, que o circuito aberto realmente pula a chamada.
- Explicar por que retry sem chave de idempotência duplica efeito em vez de corrigir falha.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Backoff exponencial | DVA-C02, SAP-C02 | espera que dobra a cada tentativa, com teto | por que ele sozinho não basta contra sincronização |
| Jitter | DVA-C02, SAP-C02 | aleatoriedade que quebra a sincronização entre clientes | a diferença entre full jitter e jitter decorrelacionado |
| Circuit breaker | DVA-C02, SAP-C02 | os três estados e a transição entre eles | que ele impede chamada NOVA, não repete a antiga |
| Retry storm / thundering herd | SAP-C02 | a causa raiz que backoff+jitter+disjuntor previnem juntos | que retry sem espaçamento agrava, não alivia, degradação |
| Idempotência e retry seguro | DVA-C02, SAP-C02 | por que retry em operação não-idempotente duplica efeito | a chave de idempotência como pré-condição do retry, não opcional |
| SQS como backoff grosseiro | DVA-C02 | visibility timeout como segunda camada de espera, atrás do retry fino | que as duas camadas coexistem e servem propósitos diferentes |
| Observabilidade de resiliência | SAP-C02, SOA-C02 | métrica publicada a cada transição do disjuntor | que um circuito "quieto" no painel de erro pode estar aberto, não saudável |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve um sistema com retry e backoff exponencial que ainda assim derruba o downstream sob carga, e pede a causa. A resposta esperada não é "aumentar o backoff" — é que falta jitter: sem ele, clientes que falharam no mesmo instante continuam retentando em ondas sincronizadas, só que mais espaçadas. O erro de raciocínio mais comum é tratar "backoff exponencial" como sinônimo de "resolvido".
Requisitos, e como cada um muda o desenho
Requisito não funcional que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Degradação do parceiro não pode virar indisponibilidade total | obrigatório | circuit breaker: acima de 50% de falha numa janela de 10 s, para de chamar |
| Retry não pode duplicar cobrança | obrigatório | a chave de idempotência do L22 é pré-condição — sem ela, nenhuma política de retry é segura |
| Cliente não pode esperar 60 s inteiros por uma recusa | meta declarada pelo time | retry com backoff fino DENTRO da invocação, antes da reentrega grosseira da fila |
| Chamadas concorrentes não podem sincronizar | obrigatório sob carga de campanha | jitter decorrelacionado em vez de backoff exponencial puro |
| Pipeline de retry cabe no timeout da função | 10 s (do L22) | timeout de 2 s por tentativa, no máximo 3 tentativas, teto de espera de 1,5 s |
| Circuito aberto tem de ser visível sem abrir código | requisito operacional | métrica customizada publicada a cada OnOpened/OnClosed/OnHalfOpened |
| Parceiro real não pode ser alvo de teste de carga | restrição contratual | simulador próprio, atrás de ALB, com capacidade calibrada pela fatura observada |
| Circuito reabrindo repetidas vezes precisa de humano | requisito operacional | alarme no CloudWatch sobre a métrica do disjuntor, não só o disjuntor sozinho |
Arquitetura mínima: o retry que multiplica a carga
Este é o desenho que o "conserto" bem-intencionado produziu, e ele é legítimo como ponto de partida: reduz a espera percebida em todo teste que já foi feito. O laboratório começa medindo o que ele faz sob a carga que ainda não foi testada.
- → 800 pedidos simulados, mesmo perfil do pico do L22
- → invocação em lote, até 10 mensagens por vez
- → até 3 tentativas imediatas, 0 ms de espera entre elas
- → encaminha para o alvo saudável
- → timeout ou 429 quando acima da capacidade
- Compute
- Integração de apps
- Rede e entrega
Este laço de três tentativas parece resiliência — e passa em qualquer teste local, onde o parceiro nunca falha. Sob carga real ele faz o oposto: cada invocação que falha vira até três, no MESMO instante, contra um parceiro que está tentando se recuperar. Percorra os passos e repare que a multiplicação é aritmética, não imprevisível.
- A concorrência escala com a fila, não com o parceiro. Cada mensagem nova dispara uma invocação nova do Lambda. Numa campanha, a fila acumula centenas de mensagens em minutos, e o Lambda escala a concorrência para atendê-las — sem nenhuma noção de quanto o downstream aguenta.
- Sem espera, a tentativa seguinte começa no mesmo instante. O laço `for` de três tentativas do código ingênuo não tem `Task.Delay` nenhum entre elas. Uma chamada que falha por timeout já gastou o tempo inteiro do timeout — e a próxima começa imediatamente, sem dar ao parceiro um único milissegundo para se recuperar.
- A multiplicação é aritmética. 800 invocações concorrentes × até 3 tentativas cada é até 2.400 requisições por segundo contra um parceiro cuja capacidade medida é de 400 req/s. A carga que o retry gera é seis vezes o que o parceiro suporta — e ela chega exatamente quando ele já estava degradando sozinho.
- O parceiro reage do jeito que qualquer serviço com limite reage. Acima da capacidade, a latência sobe e uma fração das respostas vira 429 (limite de taxa). O retry ingênuo trata 429 exatamente como qualquer outro erro: tenta de novo, imediatamente — reforçando a mesma sobrecarga que gerou o 429.
- Quem esgota as tentativas ainda pagou o preço inteiro. Uma mensagem que falha as 5 entregas da fila (o `maxReceiveCount` do L22) vai para a DLQ — mas antes de desistir, ela já gerou até 15 requisições ao parceiro (5 entregas × 3 tentativas cada). O retry não evitou o abandono; só multiplicou o custo dele.
- Por que alguém escreve o retry assim. Porque resolve um problema real — o cliente esperando até 60 s pela reentrega da fila para saber que o pagamento falhou — com a ferramenta mais curta de escrever, e porque em qualquer teste local, onde o parceiro nunca falha, o laço de 3 tentativas nunca é exercitado de verdade.
O número que importa não é "o retry existe" — é a multiplicação. Meça antes de mudar qualquer coisa: é o que torna o defeito discutível, em vez de uma sensação de que "a campanha passada foi ruim".
#!/usr/bin/env bash
# carga-ingenua.sh — 800 clientes concorrentes, cada um repetindo o laco de
# 3 tentativas SEM espera do Function.cs ingenuo. E uma aproximacao em bash
# do comportamento do Lambda sob carga (mesmo padrao de tentativas imediatas)
# — NAO e o mesmo processo .NET. Para medir o pipeline Polly de verdade, use
# o harness em C# da secao de provas.
set -euo pipefail
URL="http://$(terraform output -raw dominio_simulador)/cobrancas"
disparar_um() {
local codigo
for tentativa in 1 2 3; do
codigo=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$URL" \
-H 'content-type: application/json' \
-d '{"pedidoId":"x","valorCentavos":1000}')
[ "$codigo" = "200" ] && { echo 200; return; }
# SEM sleep: a proxima tentativa comeca no mesmo instante — e exatamente
# o defeito que este laboratorio mede.
done
echo "$codigo"
}
export -f disparar_um
export URL
inicio=$(date +%s)
seq 800 | xargs -P 800 -I{} bash -c disparar_um > /tmp/resultado-ingenuo.txt
fim=$(date +%s)
sucesso=$(grep -c '^200$' /tmp/resultado-ingenuo.txt || true)
echo "duracao: $((fim - inicio)) s"
echo "sucesso: ${sucesso} de 800"
# Medido no ambiente de exemplo da Cadencia: 267 de 800 (33%) — o parceiro
# simulado colapsa sob a propria multiplicacao do retry. Meca no seu ambiente.
Retry sem chave de idempotência não é retry — é duplicação automatizada
Este laboratório é seguro porque a chave de idempotência do L22 (a escrita condicional no DynamoDB) já existe ANTES de qualquer política de retry ser adicionada. Se o seu sistema retenta uma operação que grava efeito — cobrar cartão, debitar estoque, enviar e-mail — sem uma chave que identifique "isto já aconteceu", cada retry que POR ACASO teve sucesso depois de a resposta anterior se perder na rede duplica o efeito. Backoff e jitter não resolvem isso: eles decidem QUANDO tentar de novo, não SE é seguro tentar de novo.
Arquitetura para produção
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. A carga contra o simulador é a MESMA do desenho anterior — o que muda é o comportamento do cliente, e é isso que o teste compara.
- → mesma carga, 800 pedidos, para comparar com a Figura 1
- → invocação em lote, até 10 mensagens por vez
- → tentativa com backoff exponencial e jitter — só com o circuito fechado
- → encaminha para o alvo saudável
- → resposta ou timeout, dentro do teto de tentativas configurado
- → publica a transição do circuito
- → alarme quando o circuito soma 3 janelas abertas
- Compute
- Integração de apps
- Rede e entrega
- Gestão e governança
O que muda não é o parceiro — é a mesma carga contra o mesmo simulador. O que muda é que agora existe uma decisão ANTES de discar: com o circuito aberto, a chamada de rede simplesmente não ocorre, e essa decisão é publicada como métrica. Percorra os passos: cada peça nova rastreia a um requisito de operação sem vigília noturna.
- O jitter decorrelacionado quebra a sincronização que o backoff sozinho não quebra. Backoff exponencial puro faz todo cliente que falhou no mesmo segundo esperar o mesmo tempo — e tentar de novo no mesmo segundo seguinte. O jitter usado aqui (`UseJitter = true` com backoff exponencial, que aciona o `DecorrelatedJitterBackoffV2` do Polly) distribui a próxima tentativa num intervalo que depende da espera anterior, e é isso que impede as 800 invocações de convergirem no mesmo instante outra vez.
- O disjuntor conta falhas numa janela deslizante. Com `FailureRatio`, `SamplingDuration` e `MinimumThroughput` configurados, o circuito só decide abrir depois de observar um número mínimo de chamadas reais numa janela de tempo — abrir com uma amostra pequena demais confundiria uma falha isolada com uma tendência.
- Circuito ABERTO: a chamada de rede simplesmente não acontece. É a distinção central deste laboratório. Retry tenta de novo a MESMA chamada; circuit breaker impede uma chamada NOVA de sequer ser discada. Com o circuito aberto, `ExecuteAsync` lança `BrokenCircuitException` sem nunca invocar o delegate — o parceiro não recebe nem sabe que existiu uma tentativa.
- Toda transição é publicada, ou ninguém sabe que o circuito abriu. Sem os callbacks `OnOpened`/`OnClosed`/`OnHalfOpened` escrevendo uma linha estruturada, o efeito observável de um circuito aberto é só a AUSÊNCIA de chamadas ao parceiro — que, num painel que só mede erro, parece sucesso.
- Meio-aberto: uma chamada de teste decide o próximo estado. Depois do `BreakDuration`, o disjuntor libera exatamente UMA chamada real. Se ela tiver sucesso, o circuito fecha e o tráfego volta ao normal; se falhar, ele reabre e o teto de paciência recomeça.
- O alarme cobre o caso em que o disjuntor sozinho não é suficiente. Um circuito que abre uma vez e fecha é o sistema funcionando. Um circuito que abre, meio-abre, falha e reabre repetidamente por vários minutos é sinal de que o parceiro não está voltando — e essa distinção exige alguém olhar, não mais um retry automático.
- O resultado sob a MESMA carga é o oposto do storm. Enquanto o circuito está aberto, o throughput efetivo contra o parceiro cai para perto de zero — dando exatamente o espaço que ele precisa para se recuperar, em vez de receber seis vezes a carga normal no pior momento possível.
A diferença estrutural não é um nó a mais: é uma DECISÃO nova antes de discar (circuito fechado ou aberto?) e um caminho de observação que não existia — sem ele, o circuito abre e fecha em silêncio, e a única evidência seria a ausência de erro no painel, que parece sucesso.
O que o disjuntor entrega que o retry sozinho não entrega
Com retry e jitter mas sem circuit breaker, uma degradação SUSTENTADA do parceiro (não uma falha isolada) continua recebendo tentativa após tentativa, espaçadas mas infinitas. O disjuntor é quem decide "chega, vamos parar de tentar por um tempo" — e essa decisão é o que dá ao parceiro o espaço real para se recuperar.
O caminho de uma cobrança, ponta a ponta
O log estruturado abaixo é o que o `OnOpened` do disjuntor escreve. É texto de log, não chamada de API adicional — o metric filter do Terraform (seção de construção) é quem o transforma em número.
{
"evento": "CIRCUITO_ABERTO",
"timestamp": "2026-08-07T14:12:03.418Z",
"motivo": "TaskCanceledException: timeout apos 2000ms",
"pedidoId": "8f1c2a90-...",
"requestId": "3e7a9c10-...",
"_comentario": "Log estruturado escrito pelo OnOpened do Polly. O metric filter do Terraform casa em '$.evento = CIRCUITO_ABERTO' e vira contagem no CloudWatch — nenhuma chamada de API extra, so uma linha de log bem formatada."
}
A fila já era um backoff — só que grosseiro
O `visibility_timeout` de 60 s do L22 sempre foi uma forma de espaçar reentregas. O que este laboratório acrescenta é uma camada de espera MAIS FINA, dentro da própria invocação, antes de recorrer à reentrega grosseira da fila. As duas camadas coexistem: a fina resolve degradação de segundos; a grossa é quem ainda protege contra o caso em que o pipeline inteiro (retry + circuito) já desistiu.
As decisões, e o que se perde em cada uma
📋 O consumidor de pedidos da Cadência (L22) depende de um parceiro de pagamento que degrada sob a própria carga durante campanhas — e a equipe, sem vigília noturna, precisa que essa degradação não vire indisponibilidade completa do checkout nem cobrança duplicada.
O par retry+circuit breaker resolve dois problemas DIFERENTES que este cenário tem ao mesmo tempo: o retry com jitter dá a cada falha isolada uma segunda chance sem sincronizar com as outras; o circuit breaker impede que uma degradação sustentada do parceiro continue recebendo tráfego enquanto tenta se recuperar. Nenhum dos dois sozinho basta — retry sem disjuntor, sob degradação sustentada, continua martelando o parceiro para sempre; disjuntor sem retry desiste de falhas isoladas que uma segunda tentativa resolveria. Custa uma dependência de NuGet e algumas linhas de configuração, não um serviço novo.
Alt: Rate limiting no lado do cliente (limitar requisições/s antes de discar) — Protege o parceiro de excesso de volume, mas não resolve o problema declarado: quando o parceiro está genuinamente fora, um limitador de taxa ainda deixa passar chamadas destinadas a falhar, sem o benefício de aprender que já sabe que vão falhar.
Alt: Fila de prioridade com atraso fixo (delay queue) — Dá espaçamento, mas fixo — não se adapta a quanto o parceiro já está degradado, e não distingue "falha isolada" de "parceiro fora". É um backoff sem circuit breaker, com a mesma limitação do parágrafo acima.
Alt: Bulkhead (isolar o pool de conexões deste parceiro dos demais) — Resolve um problema adjacente e real — este parceiro degradado não pode esgotar conexões que outra chamada da mesma função precisa — mas não reduz a carga que chega até ELE. É complementar ao retry+circuit breaker, não substituto; fica registrado como extensão possível, não como este laboratório.
Alt: Aumentar o timeout da função e confiar só na reentrega da fila (60 s) — Era o desenho anterior ao problema que motivou este laboratório: funciona, mas o cliente espera até 60 s para saber que o pagamento falhou — a razão declarada pela qual alguém escreveu o retry ingênuo em primeiro lugar.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Fonte da aleatoriedade na espera | jitter decorrelacionado (`UseJitter=true` + `Exponential`) | full jitter manual; backoff fixo sem jitter | quebra a sincronização entre invocações que falharam juntas, sem exigir cálculo próprio | o algoritmo do Polly usa fatores de escala próprios — não é uma cópia literal da fórmula do artigo da AWS |
| Timeout por tentativa | 2 s (reduzido dos 5 s do L22) | 5 s (original); 500 ms (agressivo) | com retry disponível, falhar rápido e deixar o Polly decidir vale mais que uma única espera longa | uma resposta legítima que leva 3 s é tratada como falha e desperdiça uma tentativa |
| Limiar do disjuntor | 50% de falha em janela de 10 s, mínimo de 20 chamadas | 10% (agressivo); sem mínimo de amostra | não abre por causa de uma falha isolada; exige uma tendência real | em tráfego calmo, 20 chamadas em 10 s pode nunca acontecer — o disjuntor fica inerte fora de pico |
| Duração da pausa | 15 s antes de testar recuperação | 5 s (padrão do Polly); 60 s | dá tempo real ao parceiro sem manter o checkout bloqueado por minutos | se o parceiro precisar de mais que 15 s para respirar, o disjuntor testa cedo demais e reabre |
| Alvo do teste de carga | simulador próprio, atrás de ALB | chamar o parceiro real; usar `WireMock` local sem rede | reproduz throttling e latência sob carga sem violar o limite de taxa contratual | o simulador é uma APROXIMAÇÃO calibrada, não o comportamento exato do parceiro real |
A dívida que este laboratório não paga
O disjuntor aqui é local a UMA invocação do Lambda — cada execução concorrente tem a própria instância do `ResiliencePipeline` em memória, porque campos estáticos em Lambda são por ambiente de execução, não globais entre todos eles. Sob alta concorrência, isso significa várias "opiniões" independentes sobre o estado do circuito, convergindo apenas pela métrica compartilhada no CloudWatch. Um disjuntor compartilhado de verdade (estado centralizado) é material do L57.
Construir: o retry ingênuo que "conserta" e piora
Este é o código que qualquer revisão de PR aprovaria: compila, resolve o teste local, e "obviamente" tenta de novo antes de desistir. É o ponto de partida deste laboratório, não o destino.
// Function.cs — o "conserto" que piora tudo sob carga (NÃO faça isto)
//
// Ponto de partida: o L22 termina com uma única tentativa em CobrarCartaoAsync,
// deixando a fila reentregar depois de 60 s quando falha. Um engenheiro,
// incomodado com o cliente esperando ate 60 s por uma recusa, adiciona este
// laco — e e exatamente esta mudanca que faz uma degradacao do parceiro virar
// uma queda completa sob carga de campanha.
private static async Task CobrarCartaoAsync(PedidoRecebido pedido)
{
HttpResponseMessage? ultimaResposta = null;
// Tres tentativas, SEM Task.Delay nenhum entre elas. Cada chamada que
// falha por timeout ja gastou o tempo inteiro do timeout — e a proxima
// comeca no MESMO instante, sem dar ao parceiro um unico milissegundo
// para se recuperar. Sob 800 invocacoes concorrentes, isto multiplica a
// carga contra o parceiro por ate 3x exatamente quando ele mais precisa
// de MENOS carga, nao mais.
for (var tentativa = 1; tentativa <= 3; tentativa++)
{
ultimaResposta = await _gatewayPagamento.PostAsJsonAsync(
"https://gateway.exemplo/cobrancas",
new { pedido.PedidoId, pedido.ValorCentavos });
if (ultimaResposta.IsSuccessStatusCode) return;
// Um 429 (limite de taxa do parceiro) e tratado IGUAL a qualquer
// outro erro: tenta de novo, imediatamente. E o proprio sinal que o
// parceiro usa para pedir menos carga sendo respondido com mais.
}
ultimaResposta!.EnsureSuccessStatusCode(); // lanca; o item entra em batchItemFailures
}
Por que este código passa despercebido
Em qualquer ambiente de desenvolvimento, o parceiro simulado local responde em milissegundos e nunca falha — então o laço de 3 tentativas simplesmente nunca executa a segunda iteração. O defeito só aparece sob duas condições simultâneas: o parceiro degradando E concorrência suficiente para a multiplicação importar. Nenhuma das duas aparece em teste unitário.
Construir: o pipeline Polly — retry, jitter e circuit breaker
O pipeline é construído UMA VEZ, como campo estático — não a cada invocação. Reconstruir a cada chamada descartaria o estado acumulado do disjuntor, que só faz sentido observando várias chamadas ao longo do tempo.
// Function.cs — retry com backoff + jitter, e circuit breaker, como campos
// estaticos construidos UMA VEZ (fora do handler — Polly nao foi feito para
// ser reconstruido a cada invocacao, e reconstruir descartaria o estado do
// disjuntor entre chamadas).
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
private static readonly HttpClient _gatewayPagamento = new()
{
// Reduzido dos 5 s do L22 de proposito: com retry disponivel, uma unica
// espera longa vale menos do que falhar rapido e deixar o Polly decidir
// a proxima tentativa. Ver a formula do orcamento na secao ao lado.
Timeout = TimeSpan.FromSeconds(2),
};
private static readonly ResiliencePipeline<HttpResponseMessage> _resiliencia =
new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.Handle<TaskCanceledException>() // timeout do HttpClient vira esta excecao
.HandleResult(r => !r.IsSuccessStatusCode),
MaxRetryAttempts = 2, // 1 tentativa inicial + 2 retentativas = 3 no total
BackoffType = DelayBackoffType.Exponential,
// O PONTO CENTRAL deste laboratorio. Sem jitter, todo invocacao
// que falhou no mesmo segundo espera o MESMO tempo e tenta de
// novo no mesmo segundo seguinte — o backoff exponencial sozinho
// ainda sincroniza. Com UseJitter=true e backoff exponencial, o
// Polly aciona o DecorrelatedJitterBackoffV2: cada espera depende
// da anterior de forma nao-deterministica, quebrando a
// sincronizacao entre as 800 invocacoes concorrentes.
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(200),
MaxDelay = TimeSpan.FromSeconds(1.5), // teto: mantem o pior caso previsivel
OnRetry = args =>
{
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(new
{
evento = "RETRY_TENTATIVA",
tentativa = args.AttemptNumber + 1,
espera_ms = args.RetryDelay.TotalMilliseconds,
}));
return default;
},
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.Handle<TaskCanceledException>()
.HandleResult(r => !r.IsSuccessStatusCode),
// Numa janela de 10 s, com pelo menos 20 chamadas reais, abre se
// metade delas falhar. MinimumThroughput existe para nao abrir o
// circuito com base numa amostra pequena demais — mas isso tem
// um efeito colateral que a secao de troubleshooting trata: em
// trafego calmo (fora de campanha), 20 chamadas em 10 s pode
// nunca acontecer, e o disjuntor nunca abre, mesmo com o parceiro
// genuinamente fora.
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(10),
MinimumThroughput = 20,
BreakDuration = TimeSpan.FromSeconds(15),
OnOpened = args =>
{
// Log ESTRUTURADO, nao chamada de API — o metric filter do
// Terraform ao lado transforma esta linha em metrica, sem
// exigir nenhuma permissao nova para a funcao.
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(new
{
evento = "CIRCUITO_ABERTO",
motivo = args.Outcome.Exception?.Message ?? "limiar de falha atingido",
}));
return default;
},
OnClosed = args =>
{
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(
new { evento = "CIRCUITO_FECHADO" }));
return default;
},
OnHalfOpened = args =>
{
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(
new { evento = "CIRCUITO_MEIO_ABERTO" }));
return default;
},
})
.Build();
private static async Task CobrarCartaoAsync(PedidoRecebido pedido)
{
try
{
var resposta = await _resiliencia.ExecuteAsync(async ct =>
await _gatewayPagamento.PostAsJsonAsync(
"https://gateway.exemplo/cobrancas",
new { pedido.PedidoId, pedido.ValorCentavos }, ct));
resposta.EnsureSuccessStatusCode();
}
catch (BrokenCircuitException)
{
// O circuito estava ABERTO: nenhuma chamada de rede foi feita. Isto
// e DIFERENTE de esgotar as tentativas de retry — aqui a decisao foi
// tomada ANTES de discar. A mensagem volta para a fila e sera
// reentregue quando (e se) o circuito tiver fechado de novo.
throw;
}
}
A ordem das estratégias importa
`AddRetry` antes de `AddCircuitBreaker` significa que o Polly tenta as retentativas primeiro, e é o RESULTADO FINAL dessa sequência (sucesso ou falha depois de esgotar as tentativas) que alimenta a contagem do disjuntor. Invertida a ordem, cada tentativa individual dentro do retry passaria pelo disjuntor separadamente — um desenho válido para outro caso, mas que abriria o circuito muito mais cedo aqui.
Construir: o simulador do parceiro, para reproduzir com segurança
Chamar o parceiro de pagamento real num teste de carga não é uma opção disponível: ele tem limite de taxa contratual, e o objetivo é medir o comportamento do CLIENTE, não estressar a infraestrutura de um terceiro.
// simulador/Program.cs — alvo controlado para reproduzir o storm com seguranca.
// Chamar o parceiro de pagamento REAL num teste de carga nao e uma opcao: ele
// tem limite de taxa contratual, e o objetivo aqui e medir o CLIENTE, nao
// incomodar um parceiro de producao. Este simulador reage como o parceiro
// documentado reage sob carga, com a capacidade como premissa declarada.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Capacidade medida do parceiro real ao longo de meses de fatura da
// Cadencia — NAO confirmada por documentacao publica dele. E a premissa que
// autoriza este numero especifico; ajuste-o se o seu parceiro for diferente.
const int CAPACIDADE_RPS = 400;
var emVoo = 0;
app.MapPost("/cobrancas", async (HttpContext ctx) =>
{
Interlocked.Increment(ref emVoo);
try
{
if (emVoo > CAPACIDADE_RPS)
{
// Acima da capacidade declarada: latencia alta e boa parte das
// respostas vira 429, como o parceiro real sob throttling.
await Task.Delay(Random.Shared.Next(800, 2500));
if (Random.Shared.NextDouble() < 0.6)
return Results.StatusCode(429);
}
else
{
await Task.Delay(Random.Shared.Next(80, 180)); // latencia normal do parceiro
}
return Results.Ok(new { status = "aprovado" });
}
finally
{
Interlocked.Decrement(ref emVoo);
}
});
app.MapGet("/saude", () => Results.Ok());
app.Run();
# simulador.tf — alvo controlado, atras de ALB, para o teste de carga
# Reaproveita o cluster e a VPC ja criados no L01/L22 — so a task e o alvo
# sao novos.
resource "aws_lb_target_group" "simulador" {
name = "${var.projeto}-simulador-parceiro"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.principal.id
target_type = "ip"
health_check {
path = "/saude"
interval = 10
healthy_threshold = 2
unhealthy_threshold = 2
}
}
resource "aws_ecs_task_definition" "simulador_parceiro" {
family = "${var.projeto}-simulador-parceiro"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.execucao.arn
container_definitions = jsonencode([{
name = "simulador"
image = "${aws_ecr_repository.simulador.repository_url}:${var.sha_do_simulador}"
essential = true
portMappings = [{ containerPort = 8080, protocol = "tcp" }]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.simulador.name
"awslogs-region" = var.regiao
"awslogs-stream-prefix" = "simulador"
}
}
}])
}
resource "aws_ecs_service" "simulador_parceiro" {
name = "${var.projeto}-simulador-parceiro"
cluster = aws_ecs_cluster.principal.id
task_definition = aws_ecs_task_definition.simulador_parceiro.arn
desired_count = 2
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.privada[*].id
security_groups = [aws_security_group.simulador.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.simulador.arn
container_name = "simulador"
container_port = 8080
}
}
output "dominio_simulador" {
value = aws_lb.simulador.dns_name
description = "endpoint HTTP do simulador; e contra ele que os dois testes de carga rodam"
}
O simulador é uma aproximação, declare isso ao ler os números
A capacidade de 400 req/s e a distribuição de latência do simulador vêm da fatura observada da Cadência ao longo de meses — não de documentação pública do parceiro. Os números absolutos das provas desta seção valem para ESTE simulador; o padrão (retry sincroniza, jitter quebra, disjuntor limita) generaliza — os números não.
Construir: observabilidade do disjuntor
Sem esta seção, o circuito abre e fecha de forma correta e ninguém percebe — o único efeito observável seria a ausência de chamadas ao parceiro, que num painel que só mede taxa de erro parece exatamente como sucesso.
# observabilidade-circuito.tf — a transicao do disjuntor, visivel sem abrir codigo
# O SNS (aws_sns_topic.alertas) e o mesmo do L22; nao ha topico novo aqui.
resource "aws_cloudwatch_log_metric_filter" "circuito_aberto" {
name = "${var.projeto}-circuito-pagamento-aberto"
# O log group padrao que o Lambda ja escreve — nao exige nenhuma permissao
# nova para a funcao, porque e so texto de log, nao chamada de API.
log_group_name = "/aws/lambda/${aws_lambda_function.consumidor.function_name}"
# Casa a linha estruturada que o OnOpened escreve. E TEXTO de log: o metric
# filter e quem transforma isso em numero.
pattern = "{ $.evento = \"CIRCUITO_ABERTO\" }"
metric_transformation {
name = "CircuitoAbertoPagamento"
namespace = "${var.projeto}/resiliencia"
value = "1"
unit = "Count"
}
}
# NAO dispara na primeira abertura — isso e o disjuntor fazendo o trabalho
# dele. Dispara quando ele soma 3 janelas de 1 minuto com nova abertura,
# sinal de que o parceiro nao esta voltando dentro do tempo que o disjuntor
# sozinho tolera.
resource "aws_cloudwatch_metric_alarm" "circuito_reabrindo" {
alarm_name = "${var.projeto}-circuito-pagamento-reabrindo"
namespace = "${var.projeto}/resiliencia"
metric_name = "CircuitoAbertoPagamento"
statistic = "Sum"
period = 60
evaluation_periods = 3
threshold = 1
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [aws_sns_topic.alertas.arn]
}
Implantar, e provar que o circuito realmente evita a chamada
Cinco medições. Nenhuma conclusão vem de "o teste passou" sem um número atrelado — é a diferença entre demonstrar e afirmar.
// CargaTeste/Program.cs — dispara 800 chamadas concorrentes reutilizando o
// MESMO codigo de producao (ingenuo OU Polly, por variavel de ambiente), e
// conta quantas chamadas de rede de fato aconteceram — a prova de que o
// circuito aberto realmente pula a rede, nao so espera mais para tentar de
// novo.
var usaPolly = Environment.GetEnvironmentVariable("USA_POLLY") == "true";
var url = Environment.GetEnvironmentVariable("URL_SIMULADOR")!;
var chamadasDeRede = 0;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
async Task<bool> TentarIngenuo()
{
for (var t = 0; t < 3; t++)
{
Interlocked.Increment(ref chamadasDeRede);
try
{
var r = await http.PostAsJsonAsync(url, new { pedidoId = "x", valorCentavos = 1000 });
if (r.IsSuccessStatusCode) return true;
}
catch (TaskCanceledException) { /* timeout: proxima tentativa imediata */ }
}
return false;
}
async Task<bool> TentarComPolly()
{
try
{
var r = await Resiliencia.Pipeline.ExecuteAsync(async ct =>
{
Interlocked.Increment(ref chamadasDeRede); // so incrementa quando a rede E tocada
return await http.PostAsJsonAsync(url, new { pedidoId = "x", valorCentavos = 1000 }, ct);
});
return r.IsSuccessStatusCode;
}
catch { return false; }
}
var relogio = System.Diagnostics.Stopwatch.StartNew();
var tarefas = Enumerable.Range(0, 800)
.Select(_ => usaPolly ? TentarComPolly() : TentarIngenuo());
var resultados = await Task.WhenAll(tarefas);
relogio.Stop();
Console.WriteLine($"modo: {(usaPolly ? "polly" : "ingenuo")}");
Console.WriteLine($"duracao: {relogio.Elapsed.TotalSeconds:F1} s");
Console.WriteLine($"sucesso: {resultados.Count(r => r)} de 800");
Console.WriteLine($"chamadas de rede reais: {chamadasDeRede}");
// Medido no ambiente de exemplo: ingenuo faz ate 2.400 chamadas de rede para
// 800 pedidos (3x); com Polly e o circuito abrindo, o total fica pertode
// 1.000 — a diferenca E o numero de chamadas que o disjuntor evitou.
- Prova 1 — retry storm reproduzido: rode `carga-ingenua.sh` contra o simulador. Esperado, medido no ambiente de exemplo: 267 de 800 (33%) de sucesso, com o simulador claramente saturado.
- Prova 2 — o mesmo teste, protegido: rode `CargaTeste` com `USA_POLLY=true` contra a mesma carga. Esperado: sucesso substancialmente maior que a Prova 1, porque menos chamadas concorrentes chegam ao simulador ao mesmo tempo.
- Prova 3 — o circuito realmente pula a chamada: compare `chamadasDeRede` do modo ingênuo (até 2.400, teto de 3× 800) com o modo Polly. Uma diferença grande é a prova de que o disjuntor aberto evita rede, não só espera mais.
- Prova 4 — jitter quebra a sincronização: rode `grep RETRY_TENTATIVA` nos logs e calcule o desvio padrão dos timestamps de retentativa dentro do mesmo segundo de falha. Timestamps concentrados no mesmo instante indicam jitter ausente ou mal configurado.
- Prova 5 — a transição do circuito aparece na métrica: confira `aws cloudwatch get-metric-statistics --namespace "${var.projeto}/resiliencia" --metric-name CircuitoAbertoPagamento` durante o teste. Ausência de dado aqui, com o teste rodando, significa que o `OnOpened` não está sendo chamado — o disjuntor pode estar configurado com `MinimumThroughput` alto demais para a carga do teste.
O que não foi medido em produção real
Os números de sucesso e de chamadas de rede desta seção vêm de uma execução de exemplo contra o simulador local, não de uma campanha real da Cadência. Rode as cinco provas no seu ambiente antes de decidir os limiares de produção — o padrão generaliza, o número absoluto não.
Quebrar de propósito: três falhas e o diagnóstico
| Falha provocada | Sintoma | Onde olhar | Correção |
|---|---|---|---|
| Reduzir `MinimumThroughput` para 2 em produção | circuito abre com uma única falha isolada, tráfego legítimo é rejeitado sem necessidade | métrica `CircuitoAbertoPagamento` sobe mesmo com taxa de erro real baixa | suba o limiar para uma amostra que represente tendência, não ruído — 20 foi calibrado para o volume de pico da Cadência |
| Remover `UseJitter` mantendo só o backoff exponencial | sob carga sincronizada, o throughput ainda oscila em ondas, só que mais espaçadas | timestamps de retentativa concentrados em picos regulares, não distribuídos | reative `UseJitter = true`; backoff exponencial sozinho não resolve sincronização, só adia |
| Desabilitar o circuit breaker, manter só o retry | sob degradação SUSTENTADA do parceiro, o retry martela indefinidamente, sem nunca decidir parar | chamadas de rede reais continuam altas mesmo com taxa de sucesso baixa por minutos seguidos | reative `AddCircuitBreaker`; retry resolve falha isolada, não substitui a decisão de parar de tentar |
Desabilitar o disjuntor sob carga sustentada custa dinheiro dos dois lados
Sem circuit breaker, um parceiro cobrado por chamada (mesmo as que falham) continua recebendo tráfego pelo tempo inteiro da degradação — e o seu próprio Lambda continua gastando tempo de execução faturado em chamadas que, estatisticamente, sabem que vão falhar. É a combinação mais cara: pagar duas vezes por um resultado que já era previsível.
Um serviço usa backoff exponencial (sem jitter) em todas as chamadas a um downstream que ficou instável às 14h00. Às 14h03 o time nota que o tráfego contra o downstream ainda chega em picos regulares, espaçados, em vez de distribuído. Qual é a causa mais provável?
Segurança: o que um cliente HTTP mal configurado expõe
| Risco | Probabilidade | Impacto | Prevenção | Detecção | Resposta |
|---|---|---|---|---|---|
| Retry duplica cobrança em operação não-idempotente | média sob degradação do parceiro | alto — cliente cobrado duas vezes | chave de idempotência (L22) como pré-condição de qualquer retry em escrita | reconciliação entre pedidos gravados e cobranças no gateway | estorno automatizado quando a contagem diverge |
| Circuito nunca abre por `MinimumThroughput` alto demais em tráfego calmo | alta fora de campanha | médio — degradação vira lentidão perceptível, não indisponibilidade | calibrar o limiar pelo volume de tráfego FORA de pico, não só no pico | alarme de latência p99 do checkout, independente da métrica do disjuntor | ajustar `MinimumThroughput` e `SamplingDuration` para o perfil real |
| Timeout por tentativa configurado maior que o orçamento total permite | baixa, mas grave quando ocorre | alto — função morre por `SIGKILL` sem log de erro | a fórmula do orçamento (`tentativas × timeout + esperas < timeout da função`) revisada a cada mudança de parâmetro | duração de invocação próxima do timeout configurado, sem exceção capturada | reduzir tentativas ou timeout por chamada até a conta fechar com folga |
| Retentativa em erro 4xx que não é 429 (ex.: 400 de payload inválido) | baixa | baixo a médio — desperdiça tentativas em erro que nunca vai ter sucesso | `ShouldHandle` distinguindo erro transitório de erro permanente | proporção alta de retentativas terminando no mesmo código de erro | excluir 4xx permanentes do `ShouldHandle`, deixando passar só timeout e 429/5xx |
Observabilidade: as perguntas que o painel tem de responder
- O circuito está aberto agora, e há quanto tempo?
- Quantas chamadas de rede reais foram evitadas pelo disjuntor na última hora?
- A distribuição das esperas de retry está de fato espalhada, ou concentrada em picos?
- O circuito abriu e fechou uma vez (normal) ou está oscilando (parceiro instável)?
- A taxa de sucesso do checkout caiu mais do que a taxa de sucesso do gateway sozinho — sinal de disjuntor calibrado errado?
| Alarme | Métrica | Limiar inicial | Por que esse limiar |
|---|---|---|---|
| Circuito reabrindo | soma de `CIRCUITO_ABERTO` (custom) | ≥ 1 em 3 janelas de 1 min seguidas | não dispara na primeira abertura — isso é o disjuntor funcionando; dispara quando ele não fica fechado |
| 5xx do checkout | proporção de falha reportada em `batchItemFailures` | > 5% em 5 min | separa "alguns pedidos atrasaram" de "o checkout parou de processar" |
| Tempo de invocação perto do timeout | p99 de duração do Lambda | > 8 s (do orçamento de 10 s) | avisa ANTES do `SIGKILL` silencioso, com margem para investigar |
Escala: 10, 10 mil, 1 milhão, e falha de AZ
| Cenário | O que muda | Onde a arquitetura sente primeiro |
|---|---|---|
| 10 pedidos/dia | o disjuntor quase nunca atinge `MinimumThroughput` | proteção efetiva vem quase só do retry com jitter, não do circuito |
| 10 mil pedidos/dia (pico de campanha) | concorrência do Lambda escala com a fila | é exatamente o cenário medido neste laboratório — o disjuntor entra em ação em segundos |
| 1 milhão de pedidos/dia | mesmo um circuito bem calibrado não substitui capacidade real do parceiro | a conversa muda de "cliente resiliente" para "contrato de capacidade com o parceiro" — outro problema |
| Falha de AZ do simulador (ou do parceiro real) | o circuito abre e permanece aberto além do esperado | o alarme de "circuito reabrindo" é quem distingue isso de instabilidade passageira |
Custo: o que este laboratório acrescenta à fatura
Polly é biblioteca: zero custo de infraestrutura por si só. As dimensões que pesam são indiretas — use o AWS Pricing Calculator para o seu volume; nenhum valor absoluto aqui envelhece bem.
| Cenário | Dimensões que pesam | Custo oculto |
|---|---|---|
| Sem retry nem disjuntor (linha de base do L22) | tempo de execução do Lambda por invocação | reentregas da fila cobram nova invocação inteira a cada tentativa — mais grosseiro, mas não gratuito |
| Com retry e jitter, sem disjuntor | tempo de execução × até 3 tentativas por invocação | sob degradação sustentada, o tempo gasto em tentativas fadadas ao fracasso é o custo oculto |
| Com disjuntor | tempo de execução cai perto do mínimo quando o circuito está aberto | o custo evitado é proporcional a quanto tempo o parceiro fica degradado — difícil de prever, fácil de medir depois |
O custo que ninguém mede: chamadas cobradas pelo PARCEIRO
Se o contrato do gateway de pagamento cobra por chamada (mesmo as que falham), o retry storm da arquitetura mínima paga até 3x pela mesma tentativa fadada ao fracasso — em cima do que já se pagaria pela tentativa original.
Well-Architected nos seis pilares
| Pilar | Situação | Risco | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | circuito abre e fecha sem métrica visível na mínima | ninguém sabe que o parceiro degradou até o cliente reclamar | métrica customizada + alarme de circuito reabrindo | alta |
| Segurança | nenhuma credencial nova neste laboratório | baixo — o gateway é chamado por URL pública, sem segredo embutido no código | confirmar que a URL real do parceiro usa TLS e que nenhum log grava payload de cartão | média |
| Confiabilidade | retry ingênuo agrava degradação em vez de absorvê-la | indisponibilidade total do checkout durante picos de campanha | backoff exponencial + jitter + circuit breaker, como este laboratório constrói | alta |
| Eficiência de performance | timeout por tentativa mal calibrado desperdiça tempo de invocação | função mais lenta que o necessário quando o parceiro está saudável | timeout de 2 s validado contra a latência normal medida (80–180 ms) | média |
| Otimização de custo | retry sem disjuntor paga tentativas fadadas ao fracasso | fatura do parceiro e tempo de Lambda gastos em chamadas que já se sabia que falhariam | circuit breaker interrompe o gasto assim que a tendência fica clara | média |
| Sustentabilidade | retry storm desperdiça capacidade computacional dos dois lados sem gerar valor | baixo isoladamente, mas soma com todos os outros clientes do mesmo parceiro | circuit breaker reduz o trabalho descartado no agregado do ecossistema, não só localmente | baixa |
Evolução em níveis: o que muda, e o que passa a doer
Uma tentativa só, sem retry. É o ponto de partida do L22: falha visível na primeira tentativa, cliente espera a reentrega da fila.Laço de tentativas imediatas, sem espera. Resolve a lentidão percebida em teste local; não resolve — e piora — sob carga real.Backoff exponencial com jitter decorrelacionado e circuit breaker, com métrica publicada a cada transição.Estado do circuito centralizado (ex.: ElastiCache/Redis) em vez de local a cada instância do Lambda, para que todas as execuções concorrentes decidam junto.Mais de um gateway de pagamento, com fallback automático quando o principal tem o circuito aberto (L40).Modelo prevendo degradação do parceiro ANTES do limiar de falha ser cruzado, a partir de padrão histórico de latência — ajustando o disjuntor preventivamente.Onde IA entra nesta arquitetura, e onde não entra
Aqui, IA não agrega — e forçá-la seria o antipadrão que a FFV Academy existe para evitar
Backoff, jitter e circuit breaker são mecanismos determinísticos, bem estudados desde o artigo canônico da AWS de 2015, e resolvem o problema deste laboratório sem nenhum modelo. "Usar IA para prever o melhor tempo de espera" substituiria uma fórmula auditável e previsível por uma caixa-preta, sem ganho mensurado — e um disjuntor que decide com base em previsão errada é mais perigoso que um que decide com base numa janela de contagem simples.
O ponto de entrada legítimo de IA neste domínio é o Nível 6 da escada de evolução acima: prever degradação a partir de padrão histórico, para ajustar o disjuntor ANTES do limiar reativo ser cruzado — não para substituir o mecanismo determinístico que este laboratório constrói, mas para adiantar a decisão dele. É material do L57, não deste módulo.
Anti-padrões deste laboratório
| Antipadrão | Por que alguém faz | Sintoma em produção | Forma correta |
|---|---|---|---|
| Retry imediato sem nenhuma espera | é a implementação mais curta que "parece" resiliente, e passa em teste local | retry storm sob carga real e downstream degradado | backoff exponencial com jitter, nunca espera zero entre tentativas |
| Backoff exponencial sem jitter | parece suficiente — a espera cresce, então "deve estar espaçando" | clientes que falharam juntos continuam retentando juntos, só que mais devagar | `UseJitter = true`; a aleatoriedade é o que quebra a sincronização, não o crescimento da espera |
| Retry em toda exceção, incluindo erro de payload (400) | generaliza o `catch` para "qualquer coisa que falhou, tenta de novo" | tentativas desperdiçadas em erro que nunca terá sucesso, mascarando um bug real de payload | `ShouldHandle` distinguindo erro transitório (timeout, 429, 5xx) de erro permanente (4xx de validação) |
| Retry sem chave de idempotência na operação retentada | a chave de idempotência parece um passo extra opcional | cobrança duplicada, estoque debitado duas vezes, e-mail enviado duas vezes | chave de idempotência como PRÉ-CONDIÇÃO do retry em qualquer escrita, não como melhoria posterior |
| Circuit breaker sem métrica de transição | o disjuntor "funciona sozinho", parece não precisar de observação | circuito aberto por minutos sem ninguém perceber, porque a ausência de erro parece sucesso | publicar `OnOpened`/`OnClosed`/`OnHalfOpened` como métrica, sempre |
| Calibrar o disjuntor só pelo volume de pico | é o cenário mais visível e mais discutido em reunião de campanha | fora de pico, o `MinimumThroughput` nunca é atingido e o disjuntor fica inerte mesmo com o parceiro fora | calibrar considerando o volume MÍNIMO esperado, não só o máximo |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Log/métrica | Correção |
|---|---|---|---|---|
| Circuito nunca abre mesmo com o parceiro claramente fora | `MinimumThroughput` não atingido na `SamplingDuration` | compare o volume de chamadas no período com o `MinimumThroughput` configurado | contagem de invocações vs. `CircuitoAbertoPagamento` ausente | reduza `MinimumThroughput` ou aumente `SamplingDuration` para o volume real fora de pico |
| Função morre sem exceção capturada, sem log de erro | pipeline de retry estourou o timeout da função (`SIGKILL`) | compare a duração da invocação (CloudWatch Logs) com o timeout configurado e a fórmula do orçamento | duração de invocação próxima ou igual ao timeout, sem `catch` acionado | reduza `MaxRetryAttempts`, `MaxDelay` ou o timeout por tentativa até a conta fechar com folga |
| Circuito abre e fecha repetidamente em rajadas curtas | `BreakDuration` curto demais para o tempo real de recuperação do parceiro | meça o tempo entre `CIRCUITO_ABERTO` e a chamada de teste do meio-aberto que falhou de novo | sequência `CIRCUITO_ABERTO` → `CIRCUITO_MEIO_ABERTO` → `CIRCUITO_ABERTO` repetida | aumente `BreakDuration`; o disjuntor está testando recuperação antes dela acontecer de verdade |
| Throughput cai mesmo com o parceiro saudável | timeout por tentativa mais curto que a latência normal em alguns momentos | compare a distribuição de latência normal do parceiro com o timeout configurado | proporção alta de `TaskCanceledException` sem o parceiro estar de fato degradado | aumente o timeout por tentativa, ou investigue por que a latência normal variou |
Limpeza: o que o destroy não leva
#!/usr/bin/env bash
set -euo pipefail
# A ordem importa: o simulador nao tem dependente, mas o log group e o alarme
# sobrevivem ao destroy se nao estiverem no mesmo state.
terraform destroy \
-target=aws_ecs_service.simulador_parceiro \
-target=aws_ecs_task_definition.simulador_parceiro \
-target=aws_lb_target_group.simulador \
-auto-approve
terraform destroy -auto-approve
echo "Confira manualmente: log group /aws/lambda/<projeto>-consumidor-pedidos"
echo "nao e removido por este destroy — ele pertence ao L22."
O que o destroy NÃO leva
O log group do consumidor (criado pelo L22, reaproveitado aqui) não é removido por este `destroy` — ele pertence ao módulo anterior. O metric filter e o alarme deste laboratório são removidos, mas as linhas de log estruturado (`CIRCUITO_ABERTO`) já escritas continuam contando para a retenção configurada do log group até expirarem. Nenhum recurso deste laboratório cobra hora ligada parada — Polly é biblioteca, e o simulador Fargate é o único recurso de infraestrutura, coberto pelo `destroy` acima.
Resumo: problema, peça e motivo
| Problema | Peça | Motivo |
|---|---|---|
| Clientes que falharam juntos retentam juntos | jitter decorrelacionado (`UseJitter=true`) | quebra a sincronização que backoff exponencial sozinho preserva |
| Retry sozinho nunca decide parar | circuit breaker com janela deslizante | impede chamada NOVA quando a tendência de falha já está clara |
| Circuito abre e ninguém sabe | métrica publicada em `OnOpened`/`OnClosed`/`OnHalfOpened` | transforma um estado interno do processo em dado observável |
| Retry pode duplicar efeito | chave de idempotência do L22, como pré-condição | separa "é seguro tentar de novo" de "quando tentar de novo" |
| Pipeline de retry pode estourar o timeout da função | orçamento explícito (tentativas × timeout + esperas) | torna o limite verificável em vez de descoberto em produção |
| Não dá para testar contra o parceiro real | simulador próprio atrás de ALB | reproduz o comportamento sob carga sem violar limite de taxa contratual |
- Uma mensagem sai da fila do L22 e o Lambda a processa.
- A chave de idempotência decide se é processamento novo ou reentrega.
- O pipeline Polly verifica: o circuito está fechado?
- Se sim, tenta a chamada — com timeout curto e, se falhar, backoff com jitter antes da próxima.
- Cada falha alimenta a janela do disjuntor, compartilhada entre invocações diferentes.
- Se o limiar é cruzado, o circuito abre: chamadas seguintes falham em microssegundos, sem tocar a rede.
- A transição é publicada como métrica — visível sem abrir código ou log.
- Depois do `BreakDuration`, uma chamada de teste decide: fecha (recuperou) ou reabre (ainda fora).
- Se tudo falhou, a mensagem volta para a fila e a reentrega de 60 s do L22 é a rede de segurança final.
Desafio — sem roteiro
O requisito
Force a dependência downstream a falhar 100% das chamadas por 30 segundos (um endpoint de teste que sempre devolve 500) e observe o circuit breaker reagir.
Critério de aceite — executável, não "verifique se funciona"
Nos logs, depois de um número configurado de falhas seguidas, o circuito ABRE (chamadas seguintes falham IMEDIATAMENTE, sem sequer tentar a rede) — e depois do tempo de espera configurado, ele entra em half-open e testa a recuperação sozinho.
- Dica 1: A política do Polly precisa de threshold de falha e duração de quebra configurados explicitamente (`CircuitBreakerPolicy` com `handledEventsAllowedBeforeBreaking` e `durationOfBreak`) — sem isso o padrão não existe, só o retry existe.
- Dica 2: Circuito aberto deve levantar uma exceção RÁPIDA e específica (`BrokenCircuitException`), não a mesma exceção de timeout de rede — é assim que se prova, no log, que o circuito agiu e não foi só mais uma falha de rede.
- Dica 3: Depois que o endpoint de teste voltar a responder 200, o circuito só fecha de novo depois de UMA chamada de teste bem-sucedida no estado half-open — se ele fechar antes disso, a implementação está pulando o estado intermediário.
Lembrete de limpeza
Todo recurso que este desafio criar entra no mesmo `terraform destroy` do laboratório — nada fica para trás cobrando sozinho.
Perguntas frequentes
❓ Por que meu retry com backoff exponencial ainda derruba o serviço sob carga?
❓ Qual é a diferença real entre retry e circuit breaker?
❓ Qual é a fórmula do jitter decorrelacionado?
❓ Por que reduzir o timeout por tentativa em vez de manter 5 segundos?
❓ O disjuntor protege contra qualquer degradação do downstream?
❓ Por que retry pode duplicar uma cobrança, se cada tentativa é a mesma chamada?
❓ Preciso de circuit breaker se já tenho retry com backoff e jitter?
❓ Por que o simulador de teste não pode simplesmente ser o parceiro de pagamento real?
Fixando
O circuito de um cliente Polly está no estado OPEN. A próxima chamada da aplicação ao downstream é disparada. O que acontece?
Uma função Lambda com timeout de 10 s processa uma chamada a um gateway de pagamento com `MaxRetryAttempts = 4`, timeout de 3 s por tentativa e backoff com teto de 4 s por espera. Nenhuma exceção aparece nos logs quando a função falha sob degradação do gateway. Qual é a explicação mais provável?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L22 no ar (fila, DLQ, consumidor Lambda idempotente), .NET 8, noção de HttpClient e de exceções assíncronas |
| Conhecimentos adquiridos | por que backoff exponencial sozinho ainda sincroniza; a fórmula do jitter decorrelacionado; a distinção entre o que retry resolve e o que circuit breaker resolve; como orçar o pior caso de um pipeline de retry contra o timeout da função; por que retry exige idempotência como pré-condição |
| Limitação que fica | o estado do disjuntor é local a cada instância de execução do Lambda, não compartilhado — instâncias concorrentes convergem só pela métrica, não por uma decisão única |
| Próximo exemplo recomendado | L40 — múltiplos parceiros de pagamento com fallback automático quando o circuito principal abre. Reutiliza o pipeline Polly deste módulo como a base do disjuntor por parceiro |
| Também habilitado por este módulo | L57 (previsão de degradação com dados históricos, ajustando o disjuntor preventivamente) depende do mecanismo reativo construído aqui; o padrão de orçamento de timeout se aplica a qualquer outra integração síncrona instável da Cadência |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: Polly — pollydocs.org (estratégias Retry e Circuit Breaker, API `ResiliencePipelineBuilder`, `RetryStrategyOptions`, `CircuitBreakerStrategyOptions`, os quatro estados do disjuntor — Closed, Open, HalfOpen e Isolated — e o comportamento de `UseJitter` acionando o `DecorrelatedJitterBackoffV2`); e AWS Architecture Blog — "Exponential Backoff and Jitter" (Marc Brooker), a referência canônica das três estratégias de jitter (full, equal, decorrelacionado) e da demonstração de que backoff exponencial sozinho ainda sincroniza clientes. Os valores de preço não aparecem neste módulo por decisão: use o AWS Pricing Calculator, porque preço varia por região e envelhece mais rápido que o conteúdo.
O que não foi verificado, e você deve conferir na sua conta
A capacidade de 400 req/s atribuída ao parceiro simulado, os números de sucesso das provas (267 de 800; até 2.400 chamadas de rede no modo ingênuo) e a latência normal de 80–180 ms são medições de exemplo no ambiente da Cadência, não constantes universais nem documentação do parceiro real. Da mesma forma, o algoritmo interno do `DecorrelatedJitterBackoffV2` do Polly usa fatores de escala próprios do projeto — declarado como incerteza não resolvida pela documentação oficial, não como equivalência exata com a fórmula do artigo da AWS. Meça os limiares do disjuntor e o orçamento de timeout no SEU ambiente antes de levar para produção.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…