Pular para o conteúdo
,

A chave de idempotência não pode nascer dentro da tentativa

Criar a chave de idempotência dentro da tentativa não previne duplicidade de ações. Entenda por que você deve gravar a intenção antes de agir.

Avatar de DK
DKTrabalha com Linux e Unix a mais de 23 anos e possui as certificações LPI 3, RHCE, AIX e VIO.

20 ago, 2026
16 min de leitura

Duas cobranças idênticas saem para o mesmo cliente, com segundos de diferença. O agente no WhatsApp levou timeout do provedor de pagamento, a fila reentregou a mensagem, e a segunda tentativa executou de novo. A chave de idempotência existia, e mesmo assim não segurou nada, porque foi criada dentro da tentativa. O caso acima é construído por mim para expor o mecanismo, não é incidente público que eu tenha lido.

Chave de idempotência calculada na hora da tentativa é fake de segurança. O campo existe, o log mostra o valor, o gateway aceita, e cada tentativa executa. A chave muda a cada tentativa, e uma chave que muda por tentativa não identifica nada.

Cobrança duplicada também tem consequência jurídica no Brasil. O parágrafo único do artigo 42 do Código de Defesa do Consumidor trata de devolução em dobro do valor cobrado a mais. O texto ressalva engano justificável. Quem precisa desse lado, consulte um advogado ou o jurídico da sua empresa.

Se você tem 30 segundos

  • Chave sorteada dentro da tentativa não segura repetição nenhuma.
  • Grave a intenção antes de agir, e derive a chave do payload congelado.
  • Quem impõe a unicidade é o banco, com chave primária composta, nunca um if na aplicação.
  • No exemplo deste artigo, a duplicata queima R$ 148,50 por mês só em token, e o custo operacional fica fora dessa conta.

A falha, em uma linha

Se o processo morre e a mensagem é reentregue, o handler calcula a mesma chave? Se não calcula, a chave de idempotência está errada.

Pense na senha que você tira ao entrar na agência. Ela só identifica você porque foi tirada uma vez, antes do atendimento. Quem tira uma senha nova a cada ida ao balcão é atendido de novo. Tire a senha antes de agir, e grave ela.

SORTEIO NA TENTATIVA (quebrado)
tentativa 1 -> sorteia chave A -> provedor executa
   processo morre, fila reentrega
tentativa 2 -> sorteia chave B -> provedor executa de novo

INTENCAO GRAVADA ANTES (correto)
tentativa 1 -> grava intencao -> chave A -> provedor executa
   processo morre, fila reentrega
tentativa 2 -> le intencao    -> chave A -> provedor devolve a 1a resposta

Toda chave sorteada dentro da tentativa falha nesse teste. uuid4() no handler, sem gravar nada antes de agir, falha. Relógio da tentativa falha. Saída do modelo falha.

A saída do modelo é o caso mais grave porque falha duas vezes. Ela muda no restart, como as outras, e também muda sem restart nenhum. A saída do modelo não é determinística por padrão.

A conta

Vamos ver o que acontece quando a chave de idempotência não segura e o agente repete a chamada num SaaS de cobrança multi-tenant.

Números de exemplo, para você trocar pelos do seu contrato e pela sua telemetria:

Item Valor de exemplo
Conversas no mês 30.000
Tokens por fechamento de conversa 8.000 de entrada, 600 de saída
Preço por mil tokens R$ 0,015 entrada, R$ 0,075 saída
Custo por chamada R$ 0,165
Ticket por conversa resolvida R$ 1,20
Receita do mês R$ 36.000
Conversas que repetem a chamada 3%, ou 900
Token queimado em duplicata R$ 148,50

O token duplicado come 0,41% da receita, e é o número que aparece no dashboard de custo.

Traduzindo o mesmo valor para coisas que você sente: são R$ 1.782 no ano, mantido o volume. E são 124 conversas resolvidas a mais, por mês, só para empatar com o desperdício.

O custo que o dashboard não mostra

O segundo custo, o da ação financeira duplicada, eu não calculei. Ele depende de quantas dessas 900 conversas movem dinheiro, e esse número eu não tenho.

Um Pix duplicado também não é perda cheia. O valor volta por estorno, e o que sobra é custo operacional: suporte, conciliação e o risco do artigo 42. Esse custo operacional eu não medi.

Chamada de modelo duplicada não volta por estorno. O token saiu, a resposta foi descartada, e nada quebrou para avisar.

Grave a intenção antes de agir

O rascunho do IETF para o header Idempotency-Key recomenda UUID ou identificador aleatório equivalente. Isso vale para o cliente que gera a chave de idempotência uma vez por operação lógica e a repete em toda tentativa.

A exigência real está no “uma vez”. Sem gravar antes da primeira tentativa, qualquer chave se perde no restart. Com um registro de intenção gravado antes de agir, tanto um uuid4() persistido nessa linha quanto um digest derivado do payload congelado passam no teste do restart.

Por que o endToEndId do Pix não serve

Vale desfazer uma comparação que costuma aparecer. O endToEndId do Pix tem formato E + ISPB de 8 dígitos + yyyyMMddHHmm + 11 caracteres alfanuméricos, com 32 caracteres no total. Esses 11 caracteres finais são sequencial único dentro de cada minuto, gerado pelo iniciador. Eles não derivam do payload. A especificação do Open Finance Brasil define a tolerância de horário em 12 horas para frente e para trás.

Se o worker morrer antes de gravar o sequencial que sorteou, ele sorteia outro na reentrega. O identificador do Pix falha no mesmo teste de restart, e por isso ele não serve como chave de idempotência no artefato abaixo.

O que a derivação ganha sobre o uuid4 persistido

Eu derivo o digest do payload congelado, e o ganho é um só, a chave de idempotência muda quando o conteúdo da intenção muda.

Alguém corrige o valor da linha de intenção, de R$ 180 para R$ 1.800. Com digest derivado, a chave nova não colide com a linha antiga, e o provedor trata a cobrança corrigida como ação nova. Com uuid4() persistido, a chave continua a mesma, e o provedor devolve a resposta da cobrança de R$ 180.

O segundo ganho aparece quando outro serviço precisa da mesma chave de idempotência. Ele recalcula o digest a partir dos campos, sem ler a sua linha.

O custo é real. Derivar exige uma regra de normalização por campo, versionada, e mudar uma regra muda todas as chaves. Se a sua linha de intenção nunca é corrigida e nenhum outro serviço recalcula a chave, o uuid4() persistido resolve com menos código.

Onde o digest é redundante

A tabela de intenção abaixo tem chave primária composta, gravada com on conflict do nothing antes de agir. Essa tupla já é única e estável. Para a idempotência dentro do seu banco, o digest não acrescenta nada.

O digest existe para dois usos fora dali. Ele é o valor opaco de tamanho fixo no header do provedor que carrega a identidade econômica da ação, algo que a tupla da chave primária não faz.

Isso expõe uma armadilha. Se a chave primária da intenção for (tenant_id, referencia_fatura, tipo_acao) e a mesma fatura admitir duas cobranças legítimas com valores diferentes, a segunda lê o payload fixado da primeira e sai com o valor errado.

Por isso a âncora não é a fatura. Ela é a referencia_intencao, o identificador da unidade de intenção. Na maior parte dos casos ela é a própria fatura. Quando a mesma fatura admite duas ações legítimas do mesmo tipo, ela precisa de um campo que diferencie as duas, com nome próprio.

De onde vêm os campos

O digest derivado só é estável se as entradas forem estáveis. Esse é o ponto que decide se a chave de idempotência funciona ou vira o mesmo bug com outro nome.

Duas origens servem. A primeira é campo que já existe no seu sistema antes do modelo rodar: tenant_id, cliente_id, referencia_intencao puxada da tabela de faturas. A segunda é campo extraído pelo modelo e congelado no registro de intenção, gravado antes da primeira tentativa.

Campo extraído pelo modelo e lido de novo a cada tentativa não serve. Se valor e chave_pix_destino vierem da saída da tentativa atual, o digest muda com a saída, e a derivação não protege nada.

Você precisa de pelo menos um campo da primeira origem para ancorar o registro de intenção. Sem esse campo, não existe onde apoiar a gravação, e a técnica não se aplica ao seu caso.

A função de derivação

Normalmente eu comento em inglês e consts, vars, nomes de classes e funções também em inglês. No entanto, para fins didáticos para esse artigo, os comentários estão em português.

import hashlib
import hmac
import json
import os
import unicodedata
from decimal import Decimal

SEGREDO = os.environ["IDEMPOTENCY_HMAC_KEY"].encode()

# Lista explicita por tipo de acao. Nunca "todo o payload".
# Payload inteiro faz a chave mudar quando alguem adiciona um campo novo.
CAMPOS_POR_ACAO = {
    "pix.cobranca": ("cliente_id", "valor", "chave_pix_destino", "referencia_intencao"),
    "email.cobranca": ("cliente_id", "referencia_intencao", "template"),
}

def normaliza(campo, valor):
    # cliente_id: identificador interno, comparado como texto cru.
    # Sem case fold aqui: ULID e case sensitive no nosso banco.
    if campo == "cliente_id":
        return str(valor)

    # valor: dinheiro sempre em centavos inteiros.
    # "10.00", 10.0 e Decimal("10") precisam virar o mesmo 1000.
    # Float nunca entra direto no hash: 0.1 + 0.2 muda o digest.
    if campo == "valor":
        return str(int(Decimal(str(valor)) * 100))

    if campo == "chave_pix_destino":
        texto = unicodedata.normalize("NFC", str(valor)).strip().lower()
        # Email e chave Pix valida e chega com caixa variavel do teclado.
        # A pontuacao dele faz parte do endereco, entao fica.
        if "@" in texto:
            return texto
        # CPF, CNPJ, telefone e chave aleatoria perdem toda pontuacao:
        # "111.222.333-44" e "11122233344" viram o mesmo texto.
        # Prefixo de pais nao e reconstruido: ver a ressalva no texto.
        return "".join(c for c in texto if c.isalnum())

    # referencia_intencao: chave de negocio, so trim.
    # Nao normaliza caixa: NF-e distingue maiuscula de minuscula.
    if campo == "referencia_intencao":
        return str(valor).strip()

    # template: nome do template aprovado no WhatsApp.
    # O texto renderizado fica de fora, porque muda a cada geracao do modelo.
    if campo == "template":
        return str(valor).strip().lower()

    raise ValueError(f"campo sem regra de normalizacao: {campo}")

def deriva_chave(tenant_id, tipo_acao, payload):
    # payload vem do registro de intencao, gravado antes da primeira
    # tentativa e lido igual em todas as seguintes. Se vier da saida do
    # modelo da tentativa atual, o digest muda junto com ela.
    campos = CAMPOS_POR_ACAO[tipo_acao]
    corpo = {c: normaliza(c, payload[c]) for c in campos}

    canonico = json.dumps(
        {
            "t": tenant_id,     # isola tenant: mesmo payload, chave diferente
            "a": tipo_acao,     # separa cobrar de estornar
            "v": 1,             # versao das regras de normalizacao
            "p": corpo,
        },
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    ).encode()

    # HMAC, e nao SHA256 puro. Ver a defesa no texto.
    return hmac.new(SEGREDO, canonico, hashlib.sha256).hexdigest()

A normalização do destino tem um limite que o código não resolve. +5511999999999 e 11999999999 continuam sendo dois textos diferentes depois de tirar a pontuação. Grave o telefone em uma forma só no registro de intenção, antes de derivar, ou converta para E.164 dentro da função.

A versão das regras de normalização

O v: 1 é a versão das regras de normalização. No dia em que você mudar a regra de um campo, toda chave de idempotência muda, e todo trabalho em voo vira novo. Subir a versão de propósito, num deploy planejado, é diferente de descobrir isso em produção.

Por que HMAC, e não SHA256 puro

O digest cru é adivinhável. As entradas têm pouca entropia: um cliente_id, um valor redondo, uma referência sequencial. Quem reproduz o SHA256 consegue enumerar chaves válidas do seu sistema. O HMAC segura a enumeração, e só isso.

Vazamento entre tenants é outro controle. Quem segura isso é autorização na leitura, com a busca escopada por tenant e a unicidade composta por tenant e chave. O HMAC entra como defesa adicional, nunca como a barreira principal. O digest de 64 caracteres hexadecimais cabe no limite de 255 caracteres que a Stripe documenta.

Além da recomendação da Stripe de evitar dados sensíveis e identificadores pessoais, mantenho CPF fora da chave de idempotência.

Por que não tem bucket de tempo

Um campo de janela temporal parece necessário para permitir repetição legítima, tipo a mesma cobrança no mês seguinte. Eu tirei ele, e o motivo é simples.

O bucket teria que ser congelado no registro de intenção, junto com o resto. Congelado, ele vale igual em todas as tentativas da mesma intenção, e não distingue nada entre elas. A cobrança do mês seguinte já chega com outra referencia_intencao, que muda o digest sozinha.

Se a sua ação não tem campo de negócio que se renove a cada repetição legítima, você precisa de um. Crie ele no registro de intenção, com nome próprio, em vez de esconder a função dentro de um bucket de relógio.

O que entra e o que fica de fora

Campo No hash? Por quê
tenant_id sim Payload igual em dois tenants é ação diferente
tipo_acao sim Separa cobrar de estornar com o mesmo payload
valor normalizado sim Identidade econômica, lido do registro de intenção
destino normalizado sim Trocou o destino, trocou a ação
referencia_intencao sim Chave de negócio que existe antes do modelo rodar
versao das regras sim Torna a mudança de normalização explícita
texto gerado pelo modelo não Muda a cada chamada, é a origem do bug
numero da tentativa não Colocar isso aqui é exatamente a falha
timestamp da tentativa não Muda por definição
message_id do WhatsApp não Identidade do transporte, não da ação.
trace_id, request_id não Observabilidade, não identidade
IP, user agent não Não muda a ação, muda a chave
campo novo no payload não, até revisar Entrada nova exige decisão consciente

A última linha é a que mais dói na prática. Payload serializado inteiro dentro do hash significa que qualquer campo novo, inclusive um updated_at, quebra a chave de idempotência sem aviso.

Onde a unicidade é imposta

A chave de idempotência derivada não vale nada se a verificação for feita em código de aplicação. Ler, decidir e escrever é uma corrida entre a leitura e a escrita, e ela perde exatamente pela reentrega concorrente que você quer barrar.

-- congela os campos extraidos pelo modelo, ancorado em campo que ja
-- existia antes da chamada. referencia_intencao e a unidade de intencao,
-- que pode ser a fatura ou um identificador mais fino que ela.
create table intencao (
  tenant_id            text not null,
  referencia_intencao  text not null,
  tipo_acao            text not null,
  payload              jsonb not null,   -- campos ja normalizados
  chave                text not null,    -- digest derivado do payload congelado
  criada_em            timestamptz not null default now(),
  primary key (tenant_id, referencia_intencao, tipo_acao)
);

insert into intencao (tenant_id, referencia_intencao, tipo_acao, payload, chave)
values ($1, $2, $3, $4, $5)
on conflict (tenant_id, referencia_intencao, tipo_acao) do nothing;

-- a leitura devolve o payload e a chave da primeira vez.
select payload, chave
from intencao
where tenant_id = $1 and referencia_intencao = $2 and tipo_acao = $3;

create table acao_idempotente (
  tenant_id   text not null,
  chave       text not null,
  tipo_acao   text not null,
  status      text not null,     -- iniciada | concluida | falhou
  resposta    jsonb,
  lease_ate   timestamptz,       -- ate quando a tentativa em voo detem a linha
  criada_em   timestamptz not null default now(),
  primary key (tenant_id, chave)
);

-- zero linhas afetadas significa chave ja existente: leia o status.
insert into acao_idempotente (tenant_id, chave, tipo_acao, status, lease_ate)
values ($1, $2, $3, 'iniciada', now() + interval '5 minutes')
on conflict (tenant_id, chave) do nothing;

-- leitura sempre escopada por tenant, nunca pela chave sozinha.
select status, resposta, lease_ate
from acao_idempotente
where tenant_id = $1 and chave = $2;

O fluxo completo:

Fluxo SQL

Na reentrega, o insert da intenção não faz nada e o select devolve a chave de idempotência da primeira vez. A extração nova do modelo é descartada antes de chegar ao provedor, mesmo que ela tenha divergido.

Quando o conflito não quer dizer sucesso

Conflito não quer dizer ação concluída. Se o worker morreu depois do insert e antes de gravar a resposta, a linha fica em iniciada e a resposta é nula. A morte pode ter acontecido durante a chamada ao provedor, então a ação pode ter saído.

Para o estado incerto, as saídas são estas. Repetir a chamada ao provedor com a mesma chave de idempotência, consultar o status da operação no provedor, ou assumir o lease depois que ele vence e recomeçar. Tratar iniciada como concluída devolve sucesso para uma cobrança que talvez nunca tenha saído.

Quanto tempo guardar a chave

A retenção precisa cobrir o horizonte de nova tentativa mais longo do sistema inteiro. A Meta reenvia webhook e descarta o que não foi confirmado depois de um prazo. A documentação de webhooks da Meta diz, com todas as letras, que a deduplicação é responsabilidade do seu servidor.

A Stripe, do outro lado, permite remover chaves com pelo menos 24 horas de idade, e gera requisição nova se a chave reaparecer depois disso. Seu armazenamento local precisa viver mais que o maior dos dois prazos. Sem o número da Meta confirmado, eu não calculo a folga de um TTL de 24 horas no seu lado. Meça o prazo real de reentrega no seu tráfego antes de escolher o TTL.

Segunda-feira

Abra o handler do job que move dinheiro e procure por uuid4, now(), time() e message_id no caminho que monta a chave de idempotência. Cada um desses é um sorteio na hora da tentativa, a menos que o valor seja gravado antes de agir.

Depois faça o teste do restart. Grave o registro de intenção, mate o processo, reentregue a mesma mensagem e imprima a chave de idempotência calculada nas duas vezes. Chave diferente com o payload congelado significa que ainda tem sorteio no caminho.

Rode também a mesma cobrança com o CPF pontuado e sem pontuação, nas duas execuções. Digest diferente entre as duas significa que a normalização do destino ainda deixa passar duplicata.


Fontes

Avatar de DK

Comentários

Deixe um comentário

Seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Ir para