Quando o código idempotente mente para você

Um código idempotente promete que executar a mesma operação várias vezes produz o mesmo efeito de uma única execução. Em sistemas distribuídos, APIs e arquiteturas orientadas a mensagens, essa promessa costuma ser tratada como uma garantia quase absoluta.

Na prática, porém, ela frequentemente não se sustenta. Muitas implementações ditas idempotentes apenas simulam segurança e, com isso, geram inconsistências difíceis de detectar.

Esta análise parte do vídeo Your Idempotent Code Is Lying to You, publicado por Derek Comartin no CodeOpinion. Além disso, ela se apoia na documentação de arquitetura da Microsoft e em experiências com sistemas de produção.

O que é código idempotente

Do ponto de vista matemático e computacional, uma operação é idempotente quando várias execuções produzem exatamente o mesmo efeito que uma só. Por exemplo, definir o status de um pedido como pago é idempotente, enquanto somar 10 ao saldo de uma conta não é.

Em APIs REST, essa ideia aparece associada a métodos como PUT e DELETE. A RFC 9110, que define a semântica do HTTP, classifica esses métodos como idempotentes, assim como os métodos seguros, como GET e HEAD.

Em teoria, portanto, você pode chamar esses métodos repetidas vezes sem alterar o estado final do sistema. É justamente por isso que clientes HTTP e bibliotecas de resiliência repetem essas requisições automaticamente após uma falha de rede.

O problema surge quando confundimos estado final idêntico com processo idêntico. A maioria das implementações de código idempotente se preocupa apenas com o primeiro e ignora completamente o segundo.

Por que o código idempotente existe

Em sistemas distribuídos, a mesma operação chega mais de uma vez com frequência, e é por isso que o código idempotente se tornou indispensável. Isso não é um defeito raro, mas uma consequência direta de como redes e filas funcionam.

O padrão Idempotent Consumer, do Azure Architecture Center, explica que a maioria dos brokers oferece entrega pelo menos uma vez. Entre eles estão Azure Service Bus, Azure Event Hubs, Apache Kafka e RabbitMQ.

Essa garantia assegura que a mensagem chegue ao consumidor mesmo quando há falhas, mas exige um código idempotente do lado de quem consome. Por outro lado, ela também significa que a mesma mensagem pode ser entregue várias vezes.

Segundo a documentação, as duplicatas nascem principalmente de três situações:

  • O produtor reenvia a mensagem porque não recebeu a confirmação, embora o primeiro envio tenha funcionado.
  • Depois de processar a mensagem, o consumidor cai antes de confirmar o recebimento, e o broker a entrega de novo.
  • O consumidor grava no banco e falha antes do ack, então outra instância repete a gravação.

Além disso, retentativas automáticas em chamadas HTTP produzem o mesmo efeito. Se você usa o retry pattern, o servidor precisa estar pronto para receber a mesma requisição mais de uma vez.

Por isso o código idempotente virou peça obrigatória nessas arquiteturas. A questão é como ele costuma ser implementado.

A falsa sensação de segurança

Em muitos sistemas, a idempotência nasce de verificações simples. Elas costumam seguir um destes formatos:

  • Se o registro já existe, não faça nada.
  • Quando o status já está como processado, ignore.
  • Se o identificador já foi usado, retorne sucesso.

Essas abordagens parecem corretas à primeira vista. No entanto, elas falham quando o sistema sofre falhas parciais.

O código retorna sucesso, mas não garante que todos os efeitos colaterais esperados tenham acontecido de fato. Em outras palavras, o código idempotente diz que está tudo certo quando não está.

Veja um consumidor de mensagens que segue exatamente esse raciocínio:

public sealed class OrderPlacedHandler(
    AppDbContext db,
    IInventoryService inventory,
    IEmailService email)
{
    public async Task HandleAsync(OrderPlaced message)
    {
        // Verificação "idempotente": se já processou, ignora
        var alreadyProcessed = await db.ProcessedMessages
            .AnyAsync(m => m.MessageId == message.MessageId);

        if (alreadyProcessed)
            return;

        // Marca a mensagem como processada ANTES de concluir o trabalho
        db.ProcessedMessages.Add(new ProcessedMessage(message.MessageId));
        await db.SaveChangesAsync();

        // Se qualquer chamada abaixo falhar, a reentrega será ignorada
        await inventory.ReserveAsync(message.OrderId);
        await email.SendConfirmationAsync(message.OrderId);
    }
}

À primeira vista, esse código idempotente resolve as duplicatas. Contudo, se a reserva de estoque falhar, a mensagem já está marcada como processada, e nenhuma nova tentativa vai completar o trabalho.

Os efeitos colaterais ocultos do código idempotente

Sistemas reais raramente executam uma única ação. Uma requisição pode, por exemplo, executar várias etapas:

  • Persistir dados no banco.
  • Publicar eventos.
  • Enviar mensagens.
  • Acionar serviços externos.
  • Atualizar projeções ou caches.

Quando uma falha acontece no meio desse fluxo, o sistema pode registrar a operação como concluída. Enquanto isso, apenas parte dos efeitos foi aplicada.

Na nova tentativa, a lógica do código idempotente impede a reexecução, porque assume que tudo deu certo. Nesse cenário, o sistema entra em um estado inconsistente e, pior, de forma silenciosa.

Não há exceção no log nem alerta no monitoramento. O pedido existe, mas o estoque não foi reservado e o cliente nunca recebeu a confirmação.

Idempotência não é atomicidade

Um dos equívocos mais comuns é assumir que idempotência implica atomicidade. Na verdade, são conceitos distintos:

  • Idempotência garante que várias execuções não mudam o resultado final.
  • Atomicidade garante que todas as operações acontecem por completo ou nenhuma acontece.

Sem atomicidade, um código idempotente pode ser interrompido depois de cumprir só parte das suas responsabilidades. Então, a repetição não corrige o problema, porque o sistema acredita que já concluiu o trabalho.

A documentação da Microsoft chama esse intervalo de janela de falha do fluxo verificar e depois processar. Se o consumidor executa o trabalho e grava a chave em passos separados, uma queda entre os dois deixa o sistema em um estado errado.

No exemplo anterior, a ordem estava invertida e a chave vinha antes do trabalho. Se você inverter a ordem, o problema muda de lado: o trabalho acontece, a chave não é gravada e a próxima entrega repete tudo.

Em ambos os casos, portanto, o erro é o mesmo. A marcação e os efeitos colaterais não fazem parte da mesma unidade atômica.

O caso clássico: processamento de mensagens

Em arquiteturas baseadas em filas ou eventos, é comum adotar idempotência para lidar com mensagens duplicadas. O padrão típico armazena o identificador da mensagem processada e ignora repetições.

O erro acontece quando esse identificador vai para o banco antes da conclusão de todas as ações associadas à mensagem. Se o consumidor falhar depois de marcar a mensagem, a reentrega será ignorada, mesmo que parte do processamento não tenha ocorrido.

O sistema não falha de forma explícita. Ele apenas segue operando com dados incompletos, enquanto o código idempotente continua respondendo que está tudo certo.

A escolha da chave também importa para um código idempotente confiável. Segundo o Azure Architecture Center, ela precisa identificar a mensagem lógica de forma estável em todas as reentregas, como um MessageId definido pelo produtor ou uma chave de negócio.

Identificadores de transporte que o broker regenera a cada entrega não servem. Da mesma forma, o CorrelationId também não serve, porque agrupa várias mensagens relacionadas.

Garantias transacionais para código idempotente

A solução não está em abandonar a idempotência, mas em combiná-la com garantias mais fortes. Entre as abordagens possíveis estão estas:

  • Usar transações que englobem todas as alterações de estado relevantes.
  • Persistir o estado intermediário, para permitir uma retomada segura.
  • Modelar de forma explícita as etapas do processamento.
  • Separar com clareza os efeitos internos dos efeitos externos.

Em resumo, a idempotência deve valer no nível do workflow, e não apenas no nível de uma instrução ou de uma verificação condicional.

Em outras palavras, um código idempotente confiável protege o processo inteiro, e não só a primeira linha do método.

O padrão Inbox

A forma mais direta de fechar a janela de falha é gravar a marcação e os efeitos no banco na mesma transação. A documentação da Microsoft chama essa variante transacional de padrão Inbox, o par do Transactional Outbox no lado do consumidor.

No Entity Framework Core, uma única chamada a SaveChangesAsync já roda dentro de uma transação quando o provedor suporta isso. Assim, basta reunir tudo na mesma unidade de trabalho:

public sealed class OrderPlacedHandler(AppDbContext db)
{
    private const string ConsumerName = "inventory-reservation";

    public async Task HandleAsync(OrderPlaced message)
    {
        var order = await db.Orders.SingleAsync(o => o.Id == message.OrderId);

        // Efeito interno: muda o estado do pedido
        order.MarkStockAsReserved();

        // Marca a mensagem como processada na mesma unidade de trabalho
        db.ProcessedMessages.Add(new ProcessedMessage(ConsumerName, message.MessageId));

        // Efeito externo vira um registro no outbox, publicado depois
        db.OutboxMessages.Add(OutboxMessage.From(new SendOrderConfirmation(order.Id)));

        try
        {
            // Tudo ou nada: pedido, marcação e outbox são gravados juntos
            await db.SaveChangesAsync();
        }
        catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation())
        {
            // Outra instância já processou esta mensagem: é uma duplicata
        }
    }
}

Nesse desenho de código idempotente, uma reentrega encontra a marcação e para, ou não encontra nada porque a transação não terminou. Com isso, o reprocessamento fica seguro nos dois casos.

O método IsUniqueConstraintViolation é uma extensão sua, que verifica o código de erro do banco. No SQL Server e no PostgreSQL, por exemplo, cada provedor expõe esse código de um jeito diferente.

Restrição de unicidade contra concorrência

A verificação com AnyAsync do primeiro código idempotente tem ainda outro problema. Com consumidores concorrentes, duas instâncias recebem a mesma mensagem e passam pela verificação antes que qualquer uma faça o commit.

Por isso, a documentação recomenda que o banco seja o árbitro do conflito. Uma chave única na tabela de mensagens processadas resolve isso:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // A chave composta permite que vários consumidores processem a mesma mensagem
    modelBuilder.Entity<ProcessedMessage>()
        .HasKey(m => new { m.ConsumerName, m.MessageId });
}

A chave combina o nome do consumidor com o identificador da mensagem. Dessa forma, em um cenário de publish e subscribe, o primeiro assinante não impede os demais de processar a mesma mensagem.

Código idempotente orientado a estado

Nem todo efeito cabe em uma transação de banco. Chamar uma API de pagamento ou enviar um email, por exemplo, acontece fora do seu controle transacional.

Nesses casos, uma alternativa mais robusta é tratar cada operação como uma transição explícita de estado. Em vez de perguntar se aquilo já foi feito, a lógica passa a fazer três perguntas:

  • Qual é o estado atual?
  • Quais passos ainda não foram concluídos?
  • É seguro continuar a partir daqui?

O código abaixo aplica essa ideia a um fluxo de pedido:

public enum OrderStep
{
    Received,
    StockReserved,
    PaymentCaptured,
    Completed
}

public sealed class OrderWorkflow(
    AppDbContext db,
    IInventoryService inventory,
    IPaymentService payments)
{
    public async Task ResumeAsync(Guid orderId)
    {
        var process = await db.OrderProcesses.SingleAsync(p => p.OrderId == orderId);

        if (process.Step == OrderStep.Received)
        {
            // A chave de idempotência protege a chamada externa contra repetição
            await inventory.ReserveAsync(orderId, idempotencyKey: $"reserve-{orderId}");
            process.Step = OrderStep.StockReserved;
            await db.SaveChangesAsync();
        }

        if (process.Step == OrderStep.StockReserved)
        {
            await payments.CaptureAsync(orderId, idempotencyKey: $"capture-{orderId}");
            process.Step = OrderStep.PaymentCaptured;
            await db.SaveChangesAsync();
        }

        if (process.Step == OrderStep.PaymentCaptured)
        {
            process.Step = OrderStep.Completed;
            await db.SaveChangesAsync();
        }
    }
}

Cada etapa só avança depois de concluída, e o estado fica salvo entre uma etapa e outra.

Esse código idempotente não depende de uma verificação única no início, porque cada passo sabe se já foi executado. Assim, se o processo cair, a próxima execução retoma do ponto exato em que parou.

Repare, contudo, em um detalhe importante. Se a falha ocorrer entre a chamada externa e o SaveChangesAsync, a etapa vai rodar de novo.

Por isso, o fluxo repassa uma chave de idempotência para cada serviço chamado. A documentação da Microsoft reforça esse ponto: tornar o consumidor idempotente não protege os serviços que ele chama.

Para chamadas que não aceitam chave de idempotência, o Azure Architecture Center sugere uma abordagem em duas fases. Primeiro, você grava a chave com o estado em andamento e executa a ação; depois, atualiza o registro para concluído.

Um registro concluído diz ao consumidor que não precisa repetir a chamada. Já um registro em andamento sinaliza uma tentativa possivelmente parcial, que precisa de reconciliação antes de seguir.

Prefira o código idempotente por natureza

Algumas operações já nascem idempotentes e dispensam toda essa contabilidade. Um upsert por chave de negócio, uma escrita que define um valor absoluto e um PUT em um recurso produzem o mesmo resultado em qualquer número de execuções.

Portanto, sempre que possível, desenhe o código idempotente por natureza. Uma mensagem que carrega o novo status do pedido, em vez de um incremento relativo, permite aplicar a mudança como um simples upsert.

Além disso, frameworks de mensageria já oferecem deduplicação pronta. O NServiceBus e o outbox de consumidor do MassTransit, por exemplo, rastreiam mensagens recebidas pelo identificador.

A detecção de duplicatas do Azure Service Bus também ajuda, mas não substitui a lógica no consumidor. Ela atua no envio e dentro de uma janela de tempo limitada, então não impede o reprocessamento após uma reentrega.

Implicações arquiteturais

Do ponto de vista arquitetural, esse problema afeta diretamente vários tipos de sistema:

  • Sistemas distribuídos.
  • Processamentos assíncronos.
  • Integrações com terceiros.
  • Arquiteturas orientadas a eventos.

Tratar o código idempotente como uma solução isolada cria sistemas que parecem resilientes. No entanto, eles acumulam inconsistências ao longo do tempo, muitas vezes percebidas só em auditorias ou reconciliações tardias.

Por isso, a observabilidade faz parte da solução. Registre a chave de deduplicação nos logs estruturados e acompanhe uma métrica de duplicatas detectadas, porque uma taxa crescente indica problema no produtor ou nos consumidores.

Se você já usa políticas de retentativa, o artigo sobre Polly no .NET mostra como configurá-las. Lembre, porém, que toda retentativa pressupõe um código idempotente do outro lado.

Conclusão

Idempotência é uma ferramenta essencial, mas perigosa quando mal compreendida. Usada apenas como atalho para evitar duplicações, ela cria uma ilusão de segurança.

O código não falha e os logs não acusam erro. Mesmo assim, o sistema passa a operar sobre premissas falsas.

Um código idempotente de verdade combina três elementos: chave estável, marcação atômica junto com os efeitos e estado explícito para o que não cabe em uma transação.

Por fim, a pergunta que vale fazer não é mais se aquilo é idempotente. A pergunta certa é esta:

Se esse processo falhar no pior momento possível, ele consegue se recuperar corretamente?

Somente quando essa resposta é positiva podemos afirmar que a idempotência está, de fato, dizendo a verdade.

FAQ

O que é um código idempotente?

É um código que produz o mesmo efeito quando executado uma ou várias vezes. Ele permite repetir operações com segurança após falhas ou duplicatas.

Idempotência e atomicidade são a mesma coisa?

Não. Idempotência trata da repetição sem efeito adicional, enquanto atomicidade garante que um conjunto de operações acontece por inteiro ou não acontece.

Quais métodos HTTP são idempotentes?

Segundo a RFC 9110, PUT e DELETE são idempotentes, assim como os métodos seguros, como GET, HEAD e OPTIONS. O POST, por outro lado, não é.

O que é o padrão Inbox?

É a variante do Idempotent Consumer que grava a marcação da mensagem e os efeitos no banco na mesma transação. Ele complementa o Transactional Outbox no lado do consumidor.

A detecção de duplicatas do broker dispensa a idempotência no consumidor?

Não. Ela reduz duplicatas no envio dentro de uma janela de tempo, mas não evita o reprocessamento após uma reentrega.

Como lidar com chamadas externas que não entram na transação?

Repasse uma chave de idempotência para o serviço chamado ou registre a etapa como em andamento antes da chamada e como concluída depois.