Pular para o conteúdo
,

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

Sua chave de idempotência não pode ser gerada na tentativa. Use UUID ou HMAC para evitar duplicações em requisições repetidas.

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
13 min de leitura

Se você tem 40 segundos

  • Uma chave de idempotência criada dentro da tentativa não protege nada. Para o servidor, cada tentativa vira uma requisição inédita.
  • Duas soluções passam no teste: UUID v4 gravado junto com o registro da intenção, ou chave derivada com HMAC-SHA256. O UUID gravado é mais barato, e é o que Stripe e IETF recomendam.
  • Derive a chave de idempotência por campos quando quem chama não grava nada antes da chamada, ou quando produtores diferentes precisam colidir de propósito.
  • Aplique a unicidade da chave de idempotência com uma restrição (constraint) imposta pelo banco. Ela resolve a corrida, e não fecha a janela entre o efeito externo e a gravação.
  • Na recuperação, resposta vazia do destino significa desconhecido. Repetir o efeito nesse caminho é o bug que a idempotência deveria impedir.

A chave de idempotência é a defesa padrão de quem escreve em sistema externo. Todo agente repete a mesma ação mais cedo ou mais tarde.

Ela é a senha de papel da padaria, enquanto você segura o papel, o balcão sabe que é o mesmo pedido e monta o lanche uma vez só. Tirar senha nova a cada impaciência transforma você em três clientes, e saem três lanches.

A fila entrega pelo menos uma vez, o cliente estoura o tempo limite e repete, o container reinicia no meio do passo. Muitas implementações quebram na forma de gerar a chave.

Duas linhas do tempo

11-diagram01-chaves

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

O concorrente real da derivação

Nem a Stripe nem o rascunho da IETF mandam criar chave de idempotência nova a cada tentativa. As duas descrevem uma chave por intenção, reusada em todas as retentativas daquela intenção.

A Stripe sugere UUID v4, e o rascunho da IETF recomenda UUID. O alvo é outra coisa: a chave que surge durante a tentativa, caso mais frequente sendo o modelo inseri-la no próprio prompt. Ele reamostra, e a chave muda junto.

import uuid

def create_intent(conn, intent_id, tenant_id, action_type, payload):
    # The key is born with the intent record, before the first attempt.
    key = uuid.uuid4().hex
    conn.execute(
        """
        insert into intencao
            (id, tenant_id, action_type, payload, idempotency_key)
        values (%s, %s, %s, %s, %s)
        """,
        (intent_id, tenant_id, action_type, Json(payload), key),
    )
    return key

Zero normalização, zero HMAC, zero risco de um campo novo entrar no payload sem classificação. Uso esse caminho por padrão, e derivo por campos só quando não existe registro anterior à chamada. Quem gasta HMAC tendo onde gravar um UUID está criando complexidade sem receber nada.

Escolha o material da chave de idempotência

Situação Material
Você grava a intenção antes de chamar uuid4 gravado no registro
Você grava a intenção, e o destino exige chave opaca e curta hmac(segredo, id da intenção)
Quem chama não grava nada antes da chamada hmac sobre tenant, ação, payload normalizado e janela
Produtores independentes emitem a mesma intenção e devem colidir hmac sobre os campos, sem id
Duas ações iguais e legítimas em poucos minutos uuid4 ou id da intenção, nunca os campos

Escolha uma linha e fique nela. Se o id da intenção entra no material, tenant, payload normalizado e janela param de influenciar o resultado, e o resto do hash vira decoração. Nesse caso, corte a normalização campo a campo e deixe a detecção de divergência com o payload_fingerprint, gravado à parte.

O resto do artigo trata da terceira e da quarta linha, que é o caso sem estado prévio.

Campos dentro e fora do hash

Campo Entra? Regra
tenant_id sim prefixo explícito, fora do payload
action_type sim enum fechado, sem texto livre
creditor_key sim só dígitos para CPF e CNPJ, minúsculas para e-mail
amount sim Decimal com 2 casas, ROUND_HALF_UP
currency sim ISO-4217 em maiúsculas
remittance sim NFC, strip, casefold, vindo da decisão gravada
request_id não novo a cada tentativa
attempt não novo a cada tentativa
trace_id não novo a cada tentativa
created_at não relógio da tentativa, não da intenção
user_agent não apresentação

Normalize campo a campo, antes de hashear

import hashlib
import hmac
import json
import re
import unicodedata
from decimal import Decimal, ROUND_HALF_UP

BUCKET_SECONDS = 900

def normalize_amount(raw):
    # 10, 10.0 and "10.00" are the same intent.
    # Money enters the hash as a fixed scale string.
    return str(Decimal(str(raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))

def normalize_currency(raw):
    return raw.strip().upper()

def normalize_creditor_key(raw):
    # A CPF or CNPJ key is digits. Dots and dashes are typing, not identity.
    key = raw.strip()
    if re.fullmatch(r"[\d.\-/]+", key):
        return re.sub(r"\D", "", key)
    return key.casefold()

def normalize_text(raw):
    # Unicode form, case and edge spaces are display concerns.
    return unicodedata.normalize("NFC", raw).strip().casefold()

HASHED_FIELDS = {
    "creditor_key": normalize_creditor_key,
    "amount": normalize_amount,
    "currency": normalize_currency,
    "remittance": normalize_text,
}

# Each ignored field changes between attempts, or is display only.
IGNORED_FIELDS = frozenset({
    "request_id",
    "attempt",
    "trace_id",
    "created_at",
    "user_agent",
})

def derive_idempotency_key(secret, tenant_id, action_type, payload, intent_epoch):
    unknown = set(payload) - set(HASHED_FIELDS) - IGNORED_FIELDS
    if unknown:
        # Fail loud. A new field gets classified by a person,
        # never dropped in silence from the identity of the action.
        raise ValueError("unclassified fields: %s" % sorted(unknown))

    body = {
        name: rule(payload[name])
        for name, rule in HASHED_FIELDS.items()
        if name in payload
    }
    bucket = int(intent_epoch) // BUCKET_SECONDS

    material = json.dumps(
        [tenant_id, action_type, body, bucket],
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    ).encode("utf-8")

    # The server secret is what stops enumeration of plausible payloads.
    return hmac.new(secret, material, hashlib.sha256).hexdigest()

Quatro detalhes.

sort_keys=True remove a ordem do dicionário da identidade.

separators sem espaço remove a formatação.

O raise no campo desconhecido evita o modo de falha mais silencioso de todos, que é alguém adicionar um campo ao payload e ele ficar de fora do hash sem ninguém decidir isso. E o HMAC entra no lugar do SHA-256 puro porque os campos têm domínio pequeno. Valor, CPF e e-mail são enumeráveis, e hash sem segredo é recalculável por quem conhece as regras.

O remittance é texto livre gerado pelo modelo, e a própria OpenAI documenta que a saída é não determinística por padrão: o seed dá saídas “mostly deterministic”, e o system_fingerprint muda quando a configuração do provedor muda. Então ele não pode ser reamostrado na retentativa. Execute a chamada uma vez, grave a saída na história de execução e, no replay, devolva o registro.

Domínio com muito texto livre no payload é sinal para voltar ao UUID gravado.

Fixe o carimbo, não leia o relógio

O intent_epoch vem do registro da intenção. Nunca de time.time() dentro da tentativa.

chave-de-idempotencia_carimbo

Sem o congelamento, a chave de idempotência expira sozinha. Uma reentrega que se arrasta por seis horas atravessa 24 janelas de 15 minutos, e cada travessia produz uma chave nova.

A janela serve à política de retenção, e separa apenas intenções iguais que caem em buckets diferentes. Dentro do mesmo bucket ela não separa nada: duas intenções idênticas recebem a mesma chave de idempotência, e a segunda é tratada como repetição da primeira. Isso é deduplicação deliberada, e você precisa querer esse efeito.

Se o seu domínio aceita duas ações iguais em poucos minutos, você não está mais no caso sem estado, porque precisa distinguir dois registros. Volte para a primeira linha da tabela.

Aplique a unicidade no banco

create table acao_executada (
  idempotency_key     varchar(64) primary key,
  payload_fingerprint char(64)    not null,
  tenant_id           text        not null,
  action_type         text        not null,
  status              text        not null,
  response_code       int,
  response_body       jsonb,
  created_at          timestamptz not null default now(),
  expires_at          timestamptz not null
);
def claim(conn, key, fingerprint, tenant_id, action_type, ttl):
    # The unique index picks the winner, not the application.
    row = conn.execute(
        """
        insert into acao_executada
            (idempotency_key, payload_fingerprint,
             tenant_id, action_type, status, expires_at)
        values (%s, %s, %s, %s, 'in_flight', now() + %s)
        on conflict (idempotency_key) do nothing
        returning idempotency_key
        """,
        (key, fingerprint, tenant_id, action_type, ttl),
    ).fetchone()
    if row is not None:
        return "owned"  # This attempt owns the side effect.

    stored = conn.execute(
        """
        select payload_fingerprint, status
          from acao_executada
         where idempotency_key = %s
        """,
        (key,),
    ).fetchone()
    if stored.payload_fingerprint != fingerprint:
        return "payload_conflict"
    return stored.status  # 'in_flight' or 'done'.

Verificar a existência da chave de idempotência e depois agir é uma corrida entre o tempo da checagem e o tempo do uso. Ela falha justamente sob a reentrega concorrente contra a qual você está se defendendo. A constraint decide quem executa, e resolve só essa disputa.

O payload_fingerprint fica gravado separado da chave e é comparado em toda repetição. Com UUID gravado, ele é o único jeito de detectar que o mesmo identificador chegou com corpo diferente, e a Stripe faz essa comparação. Com chave derivada, ele continua útil porque o material do hash é um recorte dos campos, e nem todo campo entra.

Alguns destinos limitam o tamanho da chave de idempotência. O Open Finance Brasil exige o header x-idempotency-key no fluxo de pagamentos, com maxLength de 40. O hexdigest do HMAC-SHA256 tem 64 caracteres, então corte nos 40 primeiros quando o limite for esse. Quarenta hexadecimais são 160 bits: resistência a pré-imagem de 160 bits e cerca de 80 bits contra colisão genérica. Contra enumeração, quem protege é o segredo do HMAC, não o tamanho do corte.

A janela entre o efeito e o registro

Se o processo morre depois de aplicar o efeito e antes de gravar done, o registro fica preso em in_flight. Liberar por expiração de TTL nesse estado repete a ação. Bloquear para sempre trava o fluxo.

11-diagram02-sequencia
class LookupUndecided(Exception):
    pass

def settle(conn, key, gateway, request):
    # The same key travels to the destination. Idempotency has to exist there too.
    outcome = gateway.execute(idempotency_key=key, **request)
    _write_result(conn, key, outcome)
    return outcome

def recover(conn, key, gateway):
    # Three answers, not two. Absent and unknown are different states.
    status, outcome = gateway.lookup(idempotency_key=key)
    if status == "found":
        _write_result(conn, key, outcome)
        return outcome
    if status == "absent":
        # The destination answered, and it holds no record for this key.
        return None
    # Timeout, 5xx, lagging replica, key not indexed yet.
    raise LookupUndecided(key)

Resposta vazia e resposta indecisa são caminhos diferentes. absent só vale quando o destino documenta leitura consistente com a escrita. Se ele documenta propagação assíncrona, ou se o cliente não distingue erro de consulta de registro inexistente, trate tudo que não for found como indeciso.

O registro fica em in_flight, a consulta volta com backoff exponencial de 1 minuto, 2 minutos e 4 minutos, e o caso vai para reconciliação por extrato quando o backoff esgota.

Passe a mesma chave de idempotência ao gateway. Quando o destino não tem idempotência nem consulta por chave, a saída é outbox, reconciliação por extrato e estados recuperáveis. Nesse cenário você mitiga a repetição, e não promete execução única.

As respostas de uma chave de idempotência repetida

Situação Estado no banco Resposta
Primeira vez ausente processa normal
Repetição após concluir done devolve o resultado gravado, sucesso ou erro
Repetição com a original em voo in_flight 409 Conflict
Mesma chave, payload diferente fingerprint gravado diverge 422 Unprocessable Content
Chave ausente onde é obrigatória sem registro 400 Bad Request

Essa tabela vem do rascunho da IETF sobre o header Idempotency-Key, na versão draft-07. A Stripe implementa a mesma ideia de outra forma, ela grava o status e o corpo da primeira resposta e devolve o mesmo resultado depois, incluindo erros 500. E compara os parâmetros de entrada com os da requisição original, recusando quando divergem. O Open Finance Brasil dá nome a essa recusa, ERRO_IDEMPOTENCIA, quando o conteúdo diverge do associado à chave.

Devolver o resultado gravado é melhor que devolver erro. Erro obriga o chamador a tratar como falha algo que já deu certo.

Guarde a chave de idempotência mais tempo que a maior reentrega

Fonte O que está documentado
Stripe remove chaves com pelo menos 24 horas de idade
Meta, webhooks reenvia a entrega quando não recebe confirmação; a janela fica na documentação de cada produto, sem valor único
IETF, draft-07 exige que o recurso publique a política de expiração, sem fixar valor

Uma das três fontes publica número. Sua retenção precisa cobrir o maior horizonte entre os seus chamadores, então levante o número de cada um antes de escolher, e meça quando ele não estiver publicado. Quem recebe webhooks da Meta confere a política do produto específico. Expirar a chave de idempotência antes do fim da reentrega deixa repetições passarem direto pela defesa. Coloque TTL no registro, não delete manual, e dimensione o TTL pelo pior caso.

Entropia e segredo

O rascunho da IETF é específico sobre entropia: chave de baixa entropia permite que um atacante descubra chaves de outros clientes e leia entradas do cache de idempotência. O UUID v4 já nasce com entropia suficiente. A chave derivada não, porque os campos têm domínio pequeno, e por isso o segredo do servidor entra no HMAC. Sem ele, quem conhece as regras testa combinações plausíveis e recalcula o hash por dicionário.

O tenant_id entra no material, e o índice de unicidade continua global. A Stripe pede que a chave não carregue dado sensível, e essa regra vale nos dois caminhos. O HMAC evita expor o valor em texto claro, e não substitui a proibição. Mantenha identificador sensível fora da chave transmitida.

O modelo nunca vê a chave de idempotência. Ele propõe a ação, o orquestrador valida os parâmetros e resolve a chave. Ela sai do mesmo lugar onde mora a intenção, que é o único lugar do sistema que não muda quando a tentativa recomeça.


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