██╗██████╗ ███████╗██████╗ ██╗ █████╗ ███████╗
     ██║██╔══██╗╚══███╔╝██╔══██╗██║██╔══██╗██╔════╝
     ██║██████╔╝  ███╔╝ ██║  ██║██║███████║███████╗
██   ██║██╔═══╝  ███╔╝  ██║  ██║██║██╔══██║╚════██║
╚█████╔╝██║     ███████╗██████╔╝██║██║  ██║███████║
 ╚════╝ ╚═╝     ╚══════╝╚═════╝ ╚═╝╚═╝  ╚═╝╚══════╝   
⠀⠀
    

Idempotência: Quando Clicar Duas Vezes Vira Dois DAS

No projeto fiscal em Rails, quase aprendi na marra o que idempotência significa de verdade.

Estava testando o fluxo de apuração mensal. Clico em gerar DAS, a tela trava um segundo, clico de novo por impaciência. Dois registros na tabela. Dois valores diferentes. Mesmo período, mesma empresa. Meu estômago gelou porque fiscal não é lugar onde “desfazer” é trivial.

Sorte que era ambiente local. Em produção aquilo seria dor de cabeça real.

Idempotência é fazer a mesma operação várias vezes e chegar no mesmo resultado, sem efeito colateral extra.

Clicar duas vezes no botão não deveria gerar duas apurações. Reenviar o mesmo POST por timeout de rede não deveria cobrar duas vezes. O consumer da fila processar a mesma mensagem duas vezes não deveria debitar saldo em dobro.

Parece óbvio. Só que rede falha, browser repete request, load balancer faz retry, fila entrega at-least-once. O sistema inteiro empurra você pro cenário de duplicata o tempo todo.

Depois do susto, fui ler sobre retry automático em HTTP clients e jobs em background. A lógica parece sensata: deu timeout? Tenta de novo. Deu 503? Tenta de novo.

O problema é que o servidor pode ter processado na primeira tentativa e só a resposta se perdeu. Você manda de novo achando que falhou. Agora tem dois efeitos.

Retry e idempotência andam juntos. Sem a segunda, a primeira vira armadilha.

A solução mais direta que encontrei: o cliente manda uma chave única por operação, e o servidor guarda que aquela chave já foi processada.

No header HTTP isso aparece como Idempotency-Key. No banco, uma tabela ou constraint única na chave.

Fluxo simples:

  1. Cliente gera UUID e manda junto com o POST de apuração.
  2. Servidor tenta inserir a chave numa tabela idempotency_keys com status processing.
  3. Se a chave já existe e está completed, devolve a resposta cacheada.
  4. Se não existe, processa, salva resultado, marca completed.
def create
  key = request.headers["Idempotency-Key"]
  return render json: { error: "missing key" }, status: :bad_request if key.blank?

  existing = IdempotencyKey.find_by(key: key)
  return render json: existing.response_body if existing&.completed?

  IdempotencyKey.create!(key: key, status: :processing)

  result = ApuracaoService.call(params)
  IdempotencyKey.find_by!(key: key).update!(
    status: :completed,
    response_body: result
  )

  render json: result
rescue ActiveRecord::RecordNotUnique
  retry
end

Código simplificado, mas a ideia é essa. A constraint unique no banco é o que impede corrida entre duas requests simultâneas com a mesma chave.

No fiscal, outra camada de proteção fez sentido: unique index em (company_id, reference_month) na tabela de apurações. Mesmo sem header, o banco barra segunda apuração pro mesmo mês.

Isso não substitui idempotência de API quando a operação não tem chave natural óbvia. Mas quando existe, use. Menos moving parts.

Depois disso passei a desconfiar de botão sem loading state, de job sem deduplicação, de webhook sem verificar se o evento já foi processado.

Idempotência não é pattern de livro caro. É o que separa sistema que funciona na demo de sistema que aguenta segunda-feira de manhã com rede ruim e usuário nervoso.

Se quiser ver o contexto fiscal que originou isso, o projeto está no GitHub.

Fora isso… Obrgiado pela atenção, um abraço e até mais!

j