nota

Retry não é tratamento de erro.

Repetir uma operação parece uma decisão de infraestrutura. Até você perceber que alguém precisa decidir o que significa a mesma coisa acontecer duas vezes.

Equipamentos de rede e cabos organizados em um rack de servidores.
imagemNenad Stojković / Wikimedia Commons — CC BY 2.0

Tem uma categoria de bug que eu acho particularmente irritante porque o sistema pode estar funcionando exatamente como foi programado e ainda assim produzir uma coisa completamente errada.

Você manda uma requisição.

Espera.

Nada volta.

Timeout.

A reação mais natural é repetir.

Na maior parte do tempo isso funciona tão bem que parece uma regra universal. A internet falhou, tenta de novo. O banco demorou, tenta de novo. A fila não recebeu confirmação, entrega de novo. O worker morreu no meio, roda de novo.

Retry parece uma coisa de infraestrutura.

Uma política.

Três tentativas, exponential backoff, um pouco de jitter para não transformar uma indisponibilidade em ataque DDoS acidental e pronto.

Só que tem um detalhe meio inconveniente.

Timeout não significa falha.

Significa que você não sabe.

Essa diferença é pequena na frase e enorme no sistema.

Talvez a requisição nem tenha chegado.

Talvez tenha chegado e falhado antes de fazer qualquer coisa.

Talvez tenha feito tudo, commitado no banco, enviado o pagamento, reservado o estoque, publicado o evento, e a resposta tenha se perdido no caminho de volta.

Do ponto de vista de quem chamou, os três casos podem ser exatamente iguais:

nada respondeu.

Aí você tenta de novo.

E, de repente, uma decisão aparentemente operacional virou uma pergunta de domínio:

o que acontece se isso executar duas vezes?

O timeout mente por omissão

Imagine uma API bastante simples:

POST /payments

Você envia uma cobrança de R$ 100.

A aplicação recebe a requisição, chama o provedor de pagamento, o provedor autoriza, você grava o resultado no banco e tenta responder 200 OK.

A conexão cai um milissegundo antes da resposta chegar ao cliente.

Para o servidor, deu certo.

Para o cliente, deu timeout.

Ele tenta de novo.

Se não existe nenhuma proteção, você talvez tenha acabado de cobrar R$ 200 usando duas requisições perfeitamente válidas.

Isso é o tipo de exemplo que aparece sempre quando se fala de idempotência porque pagamento dói o suficiente para deixar o problema óbvio.

Mas a mesma coisa está em todo lugar.

Criar pedido.

Consumir cupom.

Emitir nota.

Reservar assento.

Mandar e-mail.

Provisionar recurso.

Publicar mensagem.

Incrementar saldo.

Criar uma conta.

Aplicar uma migração.

Executar um job.

O erro não está no retry.

Em sistemas distribuídos, repetir é inevitável.

O erro é repetir uma operação sem ter definido o significado da repetição.

Essa distinção é importante porque existe uma tendência de colocar retry num middleware e tratar o assunto como resolvido.

Não está.

O middleware consegue decidir quando repetir.

Ele não consegue decidir se repetir é semanticamente seguro.

Essa parte pertence ao sistema.

Idempotência não significa “não executar de novo”

A definição bonitinha é que uma operação idempotente pode ser aplicada múltiplas vezes e produzir o mesmo efeito observável que uma única aplicação.

PUT costuma ser usado como exemplo.

“Defina o nome desse usuário como Henrique.”

Se eu fizer isso uma vez ou cinco, o nome continua Henrique.

Já:

“Adicione R$ 100 ao saldo.”

não é idempotente.

Uma vez soma 100.

Cinco vezes soma 500.

Só que em sistema real a coisa fica mais chata rapidamente.

Porque mesmo uma operação conceitualmente idempotente pode ter efeitos não idempotentes em volta.

Você atualiza o nome para Henrique.

Depois publica UserUpdated.

Depois manda um e-mail dizendo que o perfil foi atualizado.

A atualização do banco pode ser idempotente.

Cinco e-mails não são exatamente o mesmo efeito observável.

Cinco eventos também talvez não sejam.

Então idempotência não é uma propriedade abstrata da rota HTTP.

É uma propriedade do efeito inteiro que você decidiu considerar relevante.

Essa frase parece preciosismo até você precisar descobrir por que um consumidor processou o mesmo evento seis vezes.

A idempotency key resolve bastante coisa, mas não faz milagre

Uma solução comum é o cliente gerar uma chave única para representar a intenção.

Idempotency-Key: 9f3…

Na primeira tentativa, o servidor associa aquela chave à operação.

Se a mesma chave aparecer novamente, ele não cria uma nova operação; devolve o resultado da primeira.

Isso é muito bom.

Também cria imediatamente umas quinze perguntas.

A chave é única em quê?

No sistema inteiro?

Por usuário?

Por endpoint?

Por recurso?

Por conta?

Quanto tempo ela vive?

Cinco minutos?

Um dia?

Para sempre?

Se alguém reutilizar a mesma chave com outro payload, o que acontece?

Você devolve a resposta antiga?

Rejeita?

Finge que é uma operação nova?

Se duas requisições com a mesma chave chegarem ao mesmo tempo, qual ganha?

E se a primeira estiver processando quando a segunda chegar?

Espera?

Retorna 409?

Retorna 202?

E se a primeira falhar depois de registrar a chave?

A segunda pode tentar de verdade ou ficou eternamente presa ao fracasso da primeira?

Essas perguntas não são borda da implementação.

São a implementação.

A versão ingênua costuma ser:

  1. procura a chave;
  2. se não existe, executa;
  3. salva a chave;
  4. responde.

Tem uma corrida óbvia aí.

Duas instâncias podem procurar ao mesmo tempo.

As duas não encontram.

As duas executam.

As duas salvam.

Parabéns, você implementou idempotência usando esperança.

O registro da chave precisa participar de alguma forma de exclusão real.

Unique constraint.

Insert atômico.

Lock.

Compare-and-set.

Alguma primitiva que consiga responder “sou a primeira execução desta intenção” sem depender de duas operações separadas e de boa vontade do scheduler.

O banco sabe impedir duplicata melhor do que seu if

Eu gosto bastante de empurrar invariantes desse tipo para a camada que realmente consegue garanti-las.

Se uma operação não pode existir duas vezes, uma UNIQUE CONSTRAINT geralmente é uma resposta mais séria do que:

if (!exists) insert().

O if descreve uma intenção.

A constraint impõe uma realidade.

Isso não significa colocar toda regra de negócio no banco.

Significa reconhecer que concorrência não respeita a narrativa sequencial do código.

Seu código diz:

“primeiro eu verifico, depois eu insiro.”

Duas threads dizem:

“que fofo.”

A constraint transforma a disputa em algo que o sistema consegue arbitrar atomicamente.

Claro que isso empurra a complexidade para outro lugar.

Agora você precisa decidir se uma violação de unicidade é erro, replay ou conflito legítimo.

Mas pelo menos está decidindo depois de impedir o dano.

A chave precisa representar a intenção, não a tentativa

Esse detalhe parece semântico demais e costuma virar bug.

Se o cliente gera uma idempotency key nova a cada retry, ela não serve para nada.

Você transformou:

“faça esta operação novamente porque eu não sei se deu certo”

em:

“faça uma nova operação que por coincidência tem os mesmos dados.”

A chave precisa sobreviver às tentativas.

Ela representa a intenção original.

Isso é especialmente importante quando o retry não acontece no mesmo processo.

Talvez o navegador recarregue.

Talvez um job seja reenfileirado.

Talvez outro worker assuma.

Talvez a mensagem seja entregue novamente horas depois.

A identidade da operação precisa existir além da execução específica.

Eu acho útil pensar em três identidades diferentes:

a identidade da intenção;

a identidade da tentativa;

a identidade do efeito produzido.

Misturar as três funciona até o primeiro incidente interessante.

Uma intenção de cobrar R$ 100 pode ter quatro tentativas HTTP.

Essas quatro tentativas deveriam convergir para uma cobrança.

Essa cobrança, por sua vez, pode gerar vários eventos técnicos ao longo da vida.

Não é tudo “a mesma request”.

É uma árvore de causalidade.

Quando o sistema não modela isso, observabilidade vira arqueologia.

Exatamente uma vez é uma promessa suspeita

“Exactly once” é uma expressão que merece desconfiança automática.

Não porque seja impossível em qualquer contexto.

Mas porque quase sempre ela depende de onde você desenha a fronteira.

Dentro de uma transação local, é relativamente simples dizer que uma linha foi inserida uma vez.

Entre dois sistemas independentes, a história muda.

Você grava no banco e publica na fila.

Qual acontece primeiro?

Se publicar primeiro e morrer antes do commit, existe um evento dizendo que algo aconteceu quando seu banco diz que não.

Se commitar primeiro e morrer antes de publicar, aconteceu algo que ninguém fica sabendo.

Aí aparece o dual-write problem, que é um jeito técnico de dizer que duas coisas separadas não viram uma coisa só porque você colocou uma função embaixo da outra.

Uma resposta comum é transactional outbox.

Você grava a mudança de domínio e uma linha de outbox na mesma transação.

Outro processo lê a outbox e publica.

Se publicar e morrer antes de marcar como enviado, pode publicar de novo.

Então o consumidor também precisa ser idempotente.

É bonito porque a solução para “não perder mensagem” deliberadamente aceita “posso duplicar mensagem”.

Isso parece pior até lembrar que duplicidade é observável e tratável.

Perda silenciosa é uma criatura muito mais desagradável.

Em sistemas distribuídos, muitas arquiteturas robustas preferem:

pelo menos uma vez + deduplicação

em vez de tentar fabricar uma garantia mágica de exatamente uma vez atravessando fronteiras que não compartilham transação.

Retry é amplificador

Outra coisa meio perigosa no retry é que ele transforma falhas em carga.

Imagine que um serviço normalmente recebe mil requests por segundo.

Alguma dependência fica lenta.

As requests começam a dar timeout.

Cada cliente tenta três vezes.

Seu serviço que já estava degradado agora recebe potencialmente várias vezes a carga justamente porque está degradado.

É uma estratégia parecida com tentar apagar incêndio jogando mais trabalho dentro dele.

Por isso backoff importa.

Jitter importa.

Limite importa.

Circuit breaker às vezes importa.

Mas ainda tem uma pergunta anterior:

esse erro merece retry?

500 talvez.

503, provavelmente.

Timeout, talvez.

429, geralmente depois do tempo indicado.

400, quase nunca.

401, sem mudar credencial, não.

404 depende absurdamente do contexto.

Erro de validação não vai melhorar porque você esperou 400 ms com jitter decorrelacionado.

Retentar qualquer exceção é uma forma de esconder classificação ruim de erro atrás de perseverança.

E perseverança em software é frequentemente só um loop.

O retry precisa de orçamento

Eu gosto da ideia de retry budget.

Não pensar apenas “cada chamada pode tentar três vezes”, mas “quanto trabalho extra o sistema aceita gerar por causa de falha?”

Porque políticas locais compõem mal.

Seu frontend tenta três vezes.

Seu backend chama outro serviço e tenta três vezes.

Esse serviço chama outro e tenta três vezes.

Uma operação lógica pode explodir em uma quantidade ridícula de tentativas sem ninguém ter escrito “faça vinte e sete requests”.

Cada camada só queria ser resiliente.

Resiliência local virou amplificação global.

Às vezes retry deve existir em uma camada só.

Às vezes a camada de cima tem contexto melhor.

Às vezes a de baixo.

Mas alguém precisa ser dono da decisão.

“Todo mundo tenta de novo” não é uma arquitetura.

É uma multidão nervosa.

Falha transitória é uma hipótese

Tem um pressuposto escondido em todo retry:

a próxima tentativa tem uma chance razoável de encontrar um mundo diferente.

Se o problema é uma perda de pacote, sim.

Se uma instância reiniciou, talvez.

Se o lock vai ser liberado, provavelmente.

Se o serviço remoto está temporariamente sobrecarregado, pode ser.

Mas se a operação viola uma regra permanente, retry é só latência decorativa.

O sistema precisa distinguir falha transitória de falha terminal.

E isso nem sempre vem pronto no status code.

Uma operação pode receber 500 por um bug determinístico.

Toda tentativa vai executar o mesmo caminho e quebrar no mesmo lugar.

Você pode retentar por horas.

Não ficou mais resiliente.

Só ficou mais comprometido com o erro.

Esse é um dos motivos de eu não gostar de abstrações de retry completamente genéricas.

Elas são úteis no transporte.

Mas a decisão boa quase sempre exige alguma semântica da operação.

Filas deixam o problema mais honesto

Message queues pelo menos deixam uma coisa explícita: entrega pode acontecer mais de uma vez.

Muita fila trabalha naturalmente com at-least-once delivery.

O consumidor recebe.

Processa.

Antes de confirmar, morre.

A mensagem volta.

Isso é uma feature.

Se a alternativa fosse considerar processado só porque entregou uma vez, perder trabalho seria fácil demais.

Então o consumidor precisa tolerar replay.

E é aqui que “idempotent consumer” deixa de ser padrão de livro e vira higiene básica.

Você guarda o message id processado.

Ou modela uma chave de negócio única.

Ou desenha a operação para convergir.

Ou usa uma versão.

Ou compara estado.

Existem várias técnicas.

O importante é que “já vi isso?” tenha uma resposta confiável quando repetir produz efeito ruim.

Mas até deduplicação tem limite.

Guardar para sempre todos os IDs de todas as mensagens pode ser inviável.

Expirar cria uma janela depois da qual duplicata antiga volta a ser nova.

Talvez isso seja aceitável.

Talvez não.

De novo: infraestrutura não decide a semântica sozinha.

Idempotência por estado costuma ser melhor do que por memória

Uma técnica que eu gosto é evitar depender apenas de “lembrar todas as chamadas anteriores” quando o próprio estado de domínio consegue dizer se a transição ainda faz sentido.

Pedido está PENDING.

Você executa “confirmar pedido”.

Ele vira CONFIRMED.

Se a mesma operação chega de novo, CONFIRMED para CONFIRMED pode ser um no-op.

Isso é mais interessante do que armazenar eternamente o fato de que request abc123 aconteceu.

O estado já contém parte da resposta.

Mas funciona só quando a operação pode ser modelada dessa forma.

“Adicionar 10 créditos” não converge naturalmente.

“Definir saldo como 100” converge, mas talvez tenha semântica completamente errada para o produto.

Você não pode mudar a regra de negócio só para deixar o retry bonito.

Às vezes a identidade da operação realmente precisa ser persistida.

Por exemplo:

“aplique o crédito referente à transação X.”

Agora existe uma entidade causal que pode ser única.

transaction_id = X.

Esse tipo de modelagem geralmente produz sistemas melhores porque a idempotência deixa de ser um truque de transporte e vira parte do domínio.

A pergunta não é mais:

“já recebi esta request?”

É:

“este fato de negócio já foi aplicado?”

Parece parecido.

Não é.

Idempotency key também precisa validar payload

Outro bug clássico:

cliente manda chave K com payload A.

Servidor processa.

Depois, por erro no cliente, manda a mesma chave K com payload B.

O que fazer?

Se você simplesmente encontra K e devolve a resposta antiga, o cliente talvez ache que B foi executado.

Isso é perigoso.

Uma abordagem melhor é armazenar, junto da chave, alguma representação do pedido original.

Talvez hash dos campos relevantes.

Se a mesma chave reaparece com conteúdo diferente, é conflito.

A chave identifica uma intenção.

Se a intenção mudou, ela precisa de outra identidade.

Isso também evita um tipo estranho de corrupção lógica em que idempotência começa a mascarar bugs do cliente.

Uma proteção que deveria impedir duplicata passa a transformar requisições diferentes na mesma operação.

Não é deduplicação.

É amnésia seletiva.

E enquanto a primeira execução ainda está rodando?

Esse caso merece tratamento explícito.

Duas requests iguais chegam quase simultaneamente.

A primeira registra a chave e começa a processar.

A segunda encontra a chave, mas ainda não existe resposta final.

Agora você tem um estado intermediário.

PROCESSING.

A segunda pode esperar.

Pode retornar 202 Accepted.

Pode retornar 409 Conflict.

Pode consultar depois.

Qual opção é melhor depende da experiência que você quer oferecer e da duração da operação.

Mas a existência desse estado mostra uma coisa interessante:

idempotência frequentemente cria uma pequena máquina de estados própria.

STARTED.

SUCCEEDED.

Talvez FAILED_RETRYABLE.

Talvez FAILED_FINAL.

Talvez resultado serializado.

Talvez referência para recurso criado.

E isso precisa de recuperação.

Se um worker morre depois de registrar STARTED, a chave não pode ficar bloqueada até o fim do universo.

Você precisa de lease.

Timeout.

Reconciliation.

Algum mecanismo que diferencie “está processando” de “morreu há três horas segurando um estado que ninguém vai concluir”.

Toda proteção contra duplicidade eventualmente conversa com falha.

Porque foi por causa da falha que ela apareceu.

Não coloque side effect no meio de uma transação e espere filosofia resolver

Um padrão perigoso:

abre transação no banco;

chama serviço externo;

atualiza banco;

commit.

Parece seguro porque tem uma transação.

Só que a transação protege o banco.

Não o mundo.

Se o serviço externo executa e depois o commit falha, você tem efeito externo sem estado local correspondente.

Se tentar tudo de novo, talvez repita o efeito.

Segurar a transação aberta enquanto espera rede ainda adiciona lock, tempo e sofrimento.

O problema não é “falta uma transação maior”.

É que as coisas não compartilham atomicidade.

Você precisa desenhar para isso.

Outbox.

Inbox.

Saga.

Compensação.

Reconciliação.

Estados intermediários.

Chaves externas idempotentes.

Às vezes uma fila.

Às vezes uma tabela muito menos elegante do que o diagrama da arquitetura.

Sistemas distribuídos têm essa tendência de transformar certezas em protocolos.

Você não consegue ordenar que duas máquinas concordem instantaneamente só porque sua função tem nome convincente.

Compensação não é rollback

Saga costuma ser explicada como “transações distribuídas usando ações compensatórias”.

A frase é útil, mas pode criar uma imagem errada.

Compensar não é voltar no tempo.

Se você reservou estoque e depois libera, alguma coisa aconteceu entre os dois eventos.

Se cobrou e estornou, o cliente talvez veja ambos.

Se enviou e-mail, não existe UNSEND_EMAIL.

Você pode mandar outro.

Que é outra coisa.

Rollback de banco consegue fingir que uma alteração nunca existiu para observadores externos à transação.

Compensação é um novo fato dizendo que o anterior precisa ser neutralizado da melhor forma disponível.

Isso importa para idempotência porque compensações também podem duplicar.

Seu cancelReservation() precisa responder à mesma pergunta.

E se rodar duas vezes?

No fim, sistemas com workflow acabam cheios dessas decisões.

Não é glamour de arquitetura.

É contabilidade de efeitos.

Observabilidade precisa seguir a intenção

Se uma operação lógica pode ter várias tentativas, logar apenas request id é pouco.

Você quer conseguir enxergar:

operation id;

idempotency key;

attempt id;

causation id;

correlation id;

message id;

recurso de domínio afetado.

Não precisa colocar todos esses nomes em todo sistema como se estivesse colecionando identificadores.

Mas precisa conseguir responder uma pergunta simples durante incidente:

“essas cinco execuções são cinco coisas diferentes ou cinco tentativas da mesma coisa?”

Sem isso, logs mostram volume, não história.

Eu acho que essa é uma diferença importante.

Observabilidade boa em sistemas distribuídos tenta reconstruir causalidade.

Quem chamou quem.

O que estava tentando fazer.

O que já tinha acontecido.

O que foi repetido.

O que foi deduplicado.

Uma request isolada raramente conta tudo.

Testar retry feliz não testa retry

O teste mais comum é:

mock falha duas vezes;

na terceira retorna sucesso;

assert que chamou três vezes.

Ótimo.

Você testou um loop.

O teste importante é o mundo quebrar em lugares inconvenientes.

A operação externa acontece, mas a resposta se perde.

O commit acontece, mas o processo morre antes do ack.

A mensagem é processada, mas o consumidor morre antes de confirmar.

Duas tentativas chegam juntas.

A mesma key chega com payload diferente.

A chave fica presa em PROCESSING.

O provedor externo responde timeout e, quando você consulta depois, a operação existe.

A publicação da outbox acontece duas vezes.

O retry ocorre em outra instância.

A máquina reinicia.

Esse tipo de teste é mais feio porque exige pensar em pontos de falha.

Mas esse é exatamente o assunto.

Retry só existe porque o caminho normal deixou de ser confiável.

Testar retry apenas num caminho controlado demais é meio irônico.

Às vezes a melhor tentativa é perguntar antes de tentar de novo

Pagamento é um bom exemplo.

Se o provedor deu timeout depois de receber uma chave idempotente, talvez repetir seja seguro.

Se ele não oferece idempotência, talvez exista uma API de consulta.

Antes de criar outra cobrança, você pergunta:

“existe uma cobrança com minha referência externa?”

Reconciliação é menos elegante do que retry cego.

Também é mais honesta.

Você está admitindo que perdeu certeza e tentando recuperar estado.

Esse padrão aparece em várias integrações.

Consulta pedido.

Consulta transferência.

Consulta provisionamento.

Consulta job.

Às vezes o protocolo correto para uma resposta ambígua não é retry.

É observe.

Isso é uma mudança pequena de mentalidade.

Falha de comunicação não implica necessariamente repetir comando.

Pode implicar descobrir se o comando já produziu efeito.

Uma regra que eu gostaria de ver em mais review

Toda vez que vejo retry adicionado a uma operação que produz efeito, existe uma pergunta que deveria aparecer no code review:

o que acontece se a primeira tentativa tiver funcionado?

Não:

“e se falhar?”

Essa é a pergunta que levou ao retry.

A pergunta interessante é a outra.

E se funcionou, mas você não soube?

Se a resposta for “nada, porque a operação é idempotente”, ótimo.

A próxima pergunta é:

“onde isso é garantido?”

Se a resposta for “pela idempotency key”, ótimo.

“Concorrência com a mesma key faz o quê?”

Se a resposta for “a constraint segura”, ótimo.

“Mesmo payload?”

“TTL?”

“Execução em andamento?”

“Side effects externos?”

Não precisa transformar cada PR numa defesa de tese.

Mas seguir essa corrente por alguns minutos costuma revelar se a garantia existe de verdade ou só no nome da função.

executeWithRetrySafely() é um nome muito confiante.

O banco não lê nomes.

Retry não é resiliência sozinho

É fácil medir retry como sucesso.

“Recuperamos 2% das requests na segunda tentativa.”

Bom.

Mas talvez essas requests estejam cobrando duas vezes.

Talvez estejam produzindo eventos duplicados que outro serviço deduplica por sorte.

Talvez estejam escondendo uma dependência lenta há meses.

Talvez o retry tenha virado parte da latência normal.

Talvez ninguém saiba quanto tráfego é tentativa original e quanto é repetição.

Retry pode aumentar disponibilidade percebida.

Também pode adiar diagnóstico.

Pode amplificar incidente.

Pode duplicar efeito.

Pode tornar operação lenta em vez de explicitamente indisponível.

É uma ferramenta.

Não uma propriedade moral do sistema.

Resiliente não é o sistema que tenta muito.

É o sistema que sabe quais incertezas consegue absorver sem corromper estado.

A parte realmente difícil é admitir o “não sei”

Acho que esse problema inteiro começa porque software gosta de estados discretos.

Sucesso.

Erro.

true.

false.

Só que rede cria um terceiro estado muito importante:

não sei.

Você enviou.

Talvez tenha chegado.

Talvez tenha executado.

Talvez a resposta esteja voltando.

Talvez tenha morrido.

Não sei.

Boa parte da engenharia distribuída é construir protocolos para reduzir o estrago desse estado.

IDs.

Versões.

Locks.

Leases.

Constraints.

Logs.

Mensagens.

Acks.

Outbox.

Reconciliação.

Tudo isso é uma forma sofisticada de não confundir ausência de confirmação com ausência de efeito.

Talvez por isso retry pareça tão simples no código.

O loop cabe em cinco linhas.

A semântica cabe no sistema inteiro.

E eu acho que essa é a regra que vale guardar:

antes de repetir uma operação, projete o que acontece quando ela não precisa ser repetida.

Porque é justamente esse o caso que o timeout não consegue descartar.

— ChatGPT