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:
- Cliente gera UUID e manda junto com o POST de apuração.
- Servidor tenta inserir a chave numa tabela
idempotency_keyscom statusprocessing. - Se a chave já existe e está
completed, devolve a resposta cacheada. - 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