Pular para o conteúdo

Idempotência

Idempotência permite retentar uma requisição de escrita depois de um timeout ou de uma queda de rede sem criar um segundo registro nem repetir o efeito. Na integração de KYT isso importa por um motivo concreto: cada análise concluída é registrada como uso da API, gera alerta quando sinalizada e dispara webhook. Uma repetição que não é reconhecida como repetição faz tudo isso duas vezes.

Operação Repetição com a mesma chave e o mesmo corpo Mesma chave com corpo diferente Chave inválida
POST /vasp/screenings 200 com a análise original e idempotent_replay: true 409 com KYT_IDEMPOTENCY_CONFLICT 400 com KYT_IDEMPOTENCY_INVALID
POST /vasp/customers 200 com o cliente original e idempotent_replay: true 409 com KYT_IDEMPOTENCY_CONFLICT 400 com invalid_idempotency_key
POST /vasp/customers/batch 207 com o recibo original e idempotent_replay: true 409 com KYT_IDEMPOTENCY_CONFLICT 400 com invalid_idempotency_key, inclusive chave ausente
POST /vasp/wallets 200 com a carteira original e idempotent_replay: true 409 com KYT_IDEMPOTENCY_CONFLICT 400 com invalid_idempotency_key

O lote exige chave explícita e vincula o recibo também ao ator da API key; não troque de chave de API para reconciliar um lote pendente. A regra de bytes abaixo descreve o screening e os cadastros individuais. POST /vasp/kyt-report não oferece deduplicação por esse header. O anexo PATCH /vasp/screenings/{id}/tx-hash é idempotente pelo próprio hash: repetir o mesmo devolve replay, outro conflita. Não presuma idempotência global. PATCH /vasp/screenings/{id}/client-decision não precisa dele: a decisão do cliente é registrada uma única vez, e a segunda chamada responde 409 com KYT_CLIENT_DECISION_ALREADY_RECORDED. Se você retentou depois de um timeout e recebeu esse 409, leia a decisão gravada em GET /vasp/screenings/{id} antes de concluir que algo falhou.

A comparação é feita sobre o resumo SHA-256 dos bytes enviados, não sobre o JSON interpretado. Reserializar o objeto com outra ordem de campos, outro espaçamento ou outra representação de número conta como corpo diferente e devolve 409. Guarde os bytes da primeira tentativa e reenvie exatamente esses bytes.

Regra Detalhe
Tamanho Até 255 bytes, depois de remover espaços das pontas. Não há mínimo
Chave vazia Equivale a não enviar o header
Caracteres Caracteres de controle são recusados. Não há restrição a alfanumérico
Prefixo reservado Chave que começa com snx-derived: é recusada: esse espaço pertence às chaves que o servidor deriva sozinho
Escopo Por tenant e por operação. A mesma chave em POST /vasp/customers e em POST /vasp/wallets não colide
Validade Não há expiração fixa. A chave declarada por você vale enquanto o registro que ela criou existir

Use UUID v4 ou um identificador derivado do evento de negócio (pedido-12345-tentativa-1). Não use timestamp puro: duas tentativas legítimas em paralelo podem colidir.

Se você não enviar Idempotency-Key mas enviar client_transaction_id, o servidor deriva uma chave a partir da operação, do resumo do corpo e de uma janela de tempo de 10 minutos. Uma repetição com o mesmo client_transaction_id e os mesmos bytes, dentro de um intervalo de 10 a 20 minutos, devolve a análise original com idempotent_replay: true em vez de criar uma segunda.

Duas chamadas idênticas simultâneas nesse modo não são processadas em paralelo: a segunda recebe 409 com KYT_IDEMPOTENCY_IN_PROGRESS e retry_after_seconds: 2. Repita depois desse intervalo e ela recebe o replay.

Sem client_transaction_id e sem o header, não há como distinguir uma repetição de uma segunda operação real, e a análise é feita de novo. Esse limite é deliberado: deduplicar às cegas apagaria uma operação que pode ser legítima.

POST /vasp/customers e POST /vasp/wallets não derivam chave. Sem o header, POST /vasp/customers com um external_id já usado no tenant responde 409 com customer_already_exists, e POST /vasp/wallets com uma carteira que o tenant já conhece responde 200 com o resultado armazenado, sem criar outra.

Gere a chave uma vez por operação e guarde-a junto da operação. Numa repetição, reenvie a mesma chave; gerar outra a cada tentativa desliga a idempotência.

Janela do terminal
CHAVE="$(uuidgen)" # uma por operação, reutilizada em toda repetição dela
curl -X POST https://crypto-api.sentinexrisk.com/api/v1/vasp/screenings \
-H "X-API-Key: $SENTINEX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $CHAVE" \
--data-binary @analise.json

--data-binary envia os bytes do arquivo sem alteração, que é o que torna a repetição reconhecível.

Repetição com a mesma chave e o mesmo corpo:

{
"success": true,
"idempotent_replay": true,
"data": { "screening_id": "…", "recommendation": "REVIEW" }
}

Mesma chave com corpo diferente:

{
"success": false,
"error": {
"code": "KYT_IDEMPOTENCY_CONFLICT",
"http_status": 409,
"message": "A Idempotency-Key foi reutilizada com outro corpo.",
"action": "Use uma nova chave para outra intenção.",
"retriable": false,
"request_id": "…"
}
}

Em POST /vasp/customers e POST /vasp/wallets o conflito usa o envelope curto, com error.code e error.message.

import { randomUUID } from 'node:crypto';
async function analisarComRetentativa(corpoEmBytes) {
const chave = randomUUID(); // gerada UMA vez por intenção de negócio
for (let tentativa = 1; tentativa <= 4; tentativa++) {
const resposta = await fetch('https://crypto-api.sentinexrisk.com/api/v1/vasp/screenings', {
method: 'POST',
headers: {
'X-API-Key': process.env.SENTINEX_API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': chave,
},
body: corpoEmBytes, // os MESMOS bytes em todas as tentativas
});
if (resposta.ok || tentativa === 4) return resposta;
const erro = (await resposta.clone().json().catch(() => ({}))).error ?? {};
const retentavel = resposta.status === 429 || resposta.status >= 500 ||
erro.code === 'KYT_IDEMPOTENCY_IN_PROGRESS';
if (!retentavel) return resposta;
// 429 da chave traz Retry-After no header; os erros da análise trazem retry_after_seconds no corpo.
const espera = Number(resposta.headers.get('Retry-After')) || erro.retry_after_seconds || 2 ** tentativa;
await new Promise((r) => setTimeout(r, espera * 1000));
}
}

A chave não muda entre tentativas, e os bytes do corpo também não. É isso que torna a retentativa segura.

  • Não impede que duas chaves diferentes criem dois registros. Para isso existe o external_id único por tenant
  • Não atravessa tenants: a chave de um tenant não interfere em outro
  • Não vale onde a operação não implementa o header. Confira a tabela do topo

Retry e nova medição são intenções diferentes

Seção intitulada “Retry e nova medição são intenções diferentes”

Retry reconcilia a análise antiga: mesma chave e mesmos bytes. Reprocessamento após HOLD exige nova análise, com nova chave e rastreabilidade para o screening anterior no seu sistema. GET e replay não consultam novamente fontes nem reaplicam configuração alterada.