Idempotência: apertar o botão duas vezes não deveria destruir o mundo

Alguém clica em “finalizar compra”, a tela demora para responder, a pessoa clica de novo. Ou a conexão cai no meio de uma chamada, o cliente não recebe confirmação e decide tentar de novo por segurança. Nenhum dos dois casos é incomum. O problema é o que acontece quando essa segunda tentativa gera uma segunda cobrança, um segundo pedido ou um segundo e-mail, porque ninguém pensou em idempotência quando desenhou aquele fluxo.

O que idempotência realmente significa

Uma operação é idempotente quando executá-la várias vezes produz o mesmo resultado que executá-la uma única vez. Não significa que a operação não tem efeito. Significa que repetir esse efeito não acumula um segundo efeito por cima do primeiro.

Isso já está embutido na especificação dos métodos HTTP, mesmo que nem todo código respeite isso na prática. GET, PUT e DELETE são definidos como idempotentes: chamar DELETE /pedidos/123 uma vez ou dez vezes deveria deixar o sistema no mesmo estado (o pedido apagado). PUT /pedidos/123 substituindo o recurso inteiro também deveria ser seguro de repetir. Já POST, por definição, não tem garantia de idempotência, porque tradicionalmente representa criar algo novo a cada chamada, o que é exatamente o comportamento que causa problema quando a chamada é sem querer duplicada.

Idempotency Keys resolvem o problema do POST

Como boa parte das operações que mais importa proteger contra duplicação (criar um pedido, processar um pagamento) usa POST, surgiu um padrão para resolver isso sem abrir mão do verbo: a chave de idempotência.

O cliente gera um identificador único para aquela operação específica e envia num cabeçalho, normalmente Idempotency-Key, junto com o POST. O servidor guarda esse identificador associado ao resultado da primeira execução. Se a mesma chave chegar de novo, seja porque o cliente reenviou por timeout ou porque alguém clicou duas vezes, o servidor não processa a operação de novo: ele devolve o resultado que já tinha guardado da primeira vez.

[HttpPost("pagamentos")]
public async Task<IActionResult> CriarPagamento(
    [FromHeader(Name = "Idempotency-Key")] string chave,
    PagamentoRequest request,
    CancellationToken cancellationToken)
{
    var resultadoExistente = await _cache.ObterAsync(chave, cancellationToken);
    if (resultadoExistente is not null)
        return Ok(resultadoExistente);

    var resultado = await _servicoPagamento.ProcessarAsync(request, cancellationToken);
    await _cache.SalvarAsync(chave, resultado, cancellationToken);

    return Ok(resultado);
}

O ponto chave é que a chave precisa ser gerada por quem faz a chamada, representando aquela tentativa específica de operação, não algo que o servidor inventa depois.

Filas de mensagens têm o mesmo problema, disfarçado

Consumidores de fila enfrentam a mesma questão por um motivo diferente. SQS padrão, por exemplo, garante entrega pelo menos uma vez (at-least-once), o que quer dizer, explicitamente, que a mesma mensagem pode chegar mais de uma vez ao consumidor. Filas FIFO reduzem a chance de duplicidade dentro de uma janela de deduplicação, mas não eliminam completamente a necessidade de o consumidor lidar com reprocessamento.

Isso significa que processar uma mensagem duas vezes não pode ter o mesmo efeito de processá-la uma vez só. Um padrão comum é guardar o identificador de cada mensagem já processada (numa tabela, num cache) e, antes de executar qualquer efeito colateral, verificar se aquele identificador já passou por ali. Se já passou, a mensagem é confirmada (ack) sem reprocessar nada.

O ponto que costuma passar despercebido

Idempotência não é sobre impedir que erros aconteçam. É sobre garantir que a reação natural a um erro, tentar de novo, não vire um problema maior que o original. Sistemas que não pensam nisso desde o início não quebram sempre. Quebram exatamente na hora em que alguém, tentando fazer a coisa certa ao repetir uma chamada que falhou, acaba causando uma cobrança duplicada ou um pedido repetido. E aí a conversa sobre confiabilidade fica bem menos teórica.