Post-mortem: o retry que transformou um timeout de 3 segundos em duas horas de banco esgotado

Escrito por

em

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.

Defina um orçamento de tempo por job, não só um número de tentativas. Em Python: 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.

Comentários

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *