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

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.

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.

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





Deixe um comentário