Um endpoint de terceiro passou a responder em 3,2 segundos onde antes respondia em 180 ms. O timeout do cliente HTTP estava configurado em 3 segundos. Quarenta minutos depois, o pool de conexões do banco primário estava saturado e a aplicação devolvia 503 em rotas que nem tocavam naquele integrador. O terceiro nunca saiu do ar — apenas degradou. Quem caiu foi o sistema que o consumia.
O cenário abaixo é reconstruído como exercício, não como incidente real, mas o encadeamento é o mais frequente em aplicações com fila de jobs e retry automático: uma dependência lenta vira indisponibilidade total porque a política de repetição amplifica carga em vez de absorvê-la. Vale destrinchar cada elo, porque o elo que a maioria das equipes corrige primeiro é o que menos importa.
Linha do tempo do incidente
T+0 — a latência p99 do endpoint de validação de documentos sobe de 180 ms para 3,2 s. Nenhum alerta dispara: o monitor de disponibilidade do integrador continua verde, porque ele responde 200.
T+2min — os workers que processam a fila de validação começam a estourar o timeout de 3 s. A política de retry configurada no framework é a padrão: três tentativas, intervalo fixo de 5 segundos. Cada job que antes ocupava um worker por 200 ms agora ocupa por aproximadamente 24 segundos — três tentativas de 3 s mais duas esperas de 5 s, somando o overhead de serialização.
T+6min — o throughput efetivo da fila cai para cerca de um centésimo do normal, mas a taxa de entrada não muda. A fila cresce. O autoscaler, configurado para reagir a profundidade de fila, sobe de 8 para 40 workers.
T+9min — aqui o incidente deixa de ser sobre o integrador. Cada worker abre uma conexão com o banco no início do job, consulta o registro, chama o terceiro, e só devolve a conexão depois de gravar o resultado. A conexão fica retida durante toda a chamada HTTP. Com 40 workers segurando conexão por ~24 s cada, o pool de 50 conexões do primário fica permanentemente ocupado.
T+11min — requisições web, que compartilham o mesmo pool, passam a esperar na fila de aquisição de conexão. O timeout de aquisição é de 30 s. As respostas HTTP da aplicação começam a passar de 30 s e o balanceador marca as instâncias como não saudáveis.
T+14min — 503 generalizado. A partir daqui, o tráfego retomado por clientes que também fazem retry acrescenta uma segunda onda de amplificação.
Por que o retry de intervalo fixo piora o quadro
Retry com intervalo fixo tem duas propriedades ruins sob degradação. A primeira é multiplicativa: com três tentativas, a carga enviada ao serviço lento é o triplo da carga original, exatamente no momento em que ele tem menos capacidade. Se a causa da lentidão for saturação do lado do terceiro — e em geral é —, a política de retry impede a recuperação.
A segunda é a sincronização. Jobs que falharam na mesma janela voltam a tentar na mesma janela, porque todos esperam os mesmos 5 segundos. O padrão de chegada, que era razoavelmente uniforme, vira uma sequência de rajadas. Esse é o efeito conhecido como thundering herd: a carga não só aumenta, ela se concentra em picos que o dimensionamento do destino não previu.
A correção é exponential backoff com jitter — a espera cresce a cada tentativa e recebe um componente aleatório que desfaz a sincronização. A variante full jitter costuma ser a melhor escolha para chamadas idempotentes, porque distribui as tentativas por todo o intervalo em vez de agrupá-las perto do teto:
import random, time
BASE = 0.2 # 200 ms
CAP = 20.0 # teto de espera
MAX_ATTEMPTS = 4
def call_with_backoff(fn):
for attempt in range(MAX_ATTEMPTS):
try:
return fn()
except TransientError:
if attempt == MAX_ATTEMPTS - 1:
raise
ceiling = min(CAP, BASE * (2 ** attempt))
time.sleep(random.uniform(0, ceiling))
Uma observação que costuma passar batida: backoff resolve a concentração, não o volume total nem o tempo de ocupação do worker. Um job que tenta quatro vezes com teto de 20 s pode ficar retido bem mais que os 24 s do incidente. Backoff sem limite de orçamento de tempo troca um problema por outro.
deadline = time.monotonic() + 8 no início e, antes de cada nova tentativa, if time.monotonic() + espera > deadline: raise. O job falha rápido e volta para a fila em vez de segurar recurso indefinidamente.O erro de arquitetura que causou o dano real
Nada do que foi descrito até aqui explica por que a página de login caiu. O retry ruim degradou a fila de validação; isso seria um incidente contido. O que espalhou o problema foi a conexão de banco retida durante a chamada de rede externa.
É um padrão sutil porque o código parece correto. Uma transação aberta no início do handler, uma chamada externa no meio, um commit no fim — a leitura linear sugere consistência. Na prática, o tempo de vida da conexão passa a ser governado pela latência de um sistema sobre o qual a equipe não tem controle algum.
A regra prática é direta: nenhuma conexão de banco deve estar aberta enquanto uma chamada de rede externa está em voo. Isso significa três blocos separados — ler e fechar, chamar o terceiro, reabrir e gravar. Quando a operação precisa ser atômica em relação ao efeito externo, o caminho é outbox ou máquina de estados persistida, não transação longa.
O segundo agravante é o pool compartilhado. Tráfego web e processamento assíncrono têm perfis de risco diferentes: o job pode esperar, a requisição do usuário não. Compartilhar o mesmo pool significa que qualquer patologia no assíncrono se propaga para o síncrono. Separar em pools distintos com limites próprios é o bulkhead clássico, e é barato de implementar — costuma ser uma string de conexão adicional e um limite menor para os workers.
As correções em ordem de retorno
Nem todas valem o mesmo. Em ordem de impacto por esforço:
| Correção | Esforço | O que resolve | O que não resolve |
|---|---|---|---|
| Separar pool de workers e pool web | Baixo | Impede que a fila derrube o tráfego de usuário | A fila continua parando quando o terceiro degrada |
| Fechar conexão antes da chamada externa | Médio | Corta a raiz da retenção; reduz uso de pool em ordens de grandeza | Exige revisar atomicidade e tratar gravação parcial |
| Backoff exponencial com jitter e orçamento de tempo | Baixo | Elimina a rajada sincronizada e a amplificação 3x | Continua tentando contra um destino já saturado |
| Circuit breaker por destino | Médio | Para de chamar depois de N falhas e falha rápido | Precisa de meia-abertura bem calibrada para não oscilar |
| Reduzir timeout do cliente para p99 + margem | Baixo | Devolve o worker mais cedo | Gera falso negativo se a margem for apertada demais |
Se houver espaço para apenas duas neste trimestre, as duas são separar os pools e fechar a conexão antes da chamada externa. Backoff e circuit breaker melhoram o comportamento sob falha; essas duas impedem que a falha atravesse a fronteira entre subsistemas. A diferença entre um incidente de fila e um incidente de plataforma está aí.
O que instrumentar para ver antes
O incidente ficou invisível por nove minutos porque a única métrica observada do integrador era disponibilidade. Quatro sinais teriam antecipado o diagnóstico:
- Latência p99 por dependência externa, não média — a média de 180 ms mascara uma cauda de 3 s por muito tempo.
- Conexões em uso sobre o tamanho do pool, com alerta na faixa de 70% a 80%, não em 100%.
- Tempo de espera na aquisição de conexão. É o indicador que sobe primeiro e o que menos aparece em dashboard.
- Taxa de retry por destino. Uma razão de tentativas por job que sai de ~1,0 e vai para 2,5 descreve o incidente inteiro em um gráfico.
Por onde começar: abrir o código dos jobs que chamam serviços externos e localizar cada trecho em que uma conexão de banco está viva durante a chamada. Essa auditoria costuma render uma lista curta, entre três e dez pontos, e cada ponto corrigido reduz a superfície de propagação de qualquer degradação futura — inclusive das que ainda não têm nome.
Deixe um comentário