Skip to content

Idempotência de Mutações ​

Toda mutação (POST, PATCH, PUT, DELETE) na API do Neemias exige o header Idempotency-Key. Isso garante que uma mesma operação não seja processada mais de uma vez, mesmo que o cliente retente a requisição por timeout ou falha de rede — essencial para a arquitetura offline-first com sincronia assíncrona.

Header Obrigatório ​

Idempotency-Key: <UUID v4>

O cliente (frontend) gera uma chave UUID v4 única para cada operação de mutação e a envia no header. Se o header estiver ausente, o backend retorna:

json
{
  "error": "VALIDATION_FAILED",
  "message": "Idempotency-Key header is required"
}

Status: 400

Ledger de Idempotência ​

A tabela idempotency_ledger armazena o registro de cada requisição processada:

sql
CREATE TABLE idempotency_ledger (
  key TEXT NOT NULL,
  user_id TEXT NOT NULL,
  payload_hash TEXT NOT NULL,
  status_code INTEGER NOT NULL,
  response_body TEXT NOT NULL,      -- JSON serializado
  created_at TEXT NOT NULL,
  PRIMARY KEY(key, user_id)
);

A chave composta (key, user_id) garante que a mesma chave de idempotência pode ser reutilizada por usuários diferentes sem conflito.

Função idempotentMutation ​

Localizada em workers/src/db/queries.ts, esta função encapsula toda a lógica de idempotência:

typescript
async function idempotentMutation(
  idempotencyKey: string | undefined,
  userId: string,
  payload: unknown,
  execute: MutationExecutor, // () => Promise<MutationResult>
): Promise<{ statusCode: number; body: Record<string, unknown> }>;

Fluxo de Execução ​

1. Verifica se idempotencyKey está presente
   └─ Ausente → 400 VALIDATION_FAILED

2. Calcula hash do payload (payloadHash)
   └─ SHA-256 do JSON canônico (RFC 8785-style, ordem de chaves irrelevante;
      corpos não-JSON são hashados como bytes crus)

3. Reivindicação atômica: INSERT ... ON CONFLICT DO NOTHING em (key, user_id)

4. Se perdeu a corrida (meta.changes = 0):
   ├─ Consulta idempotency_ledger por (key, user_id)
   ├─ payload_hash diferente → 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD
   ├─ status_code = 0 (ainda em execução) → 409 IDEMPOTENCY_IN_PROGRESS (retryable)
   └─ payload_hash igual → replay: resposta original (status_code + body + headers)

5. Se ganhou a corrida (meta.changes = 1):
   ├─ Executa a mutation (execute())
   ├─ Grava status_code + response_body + response_headers no ledger
   ├─ Falha na execução → libera a reivindicação (DELETE) para permitir retry
   └─ Retorna resultado da mutation

Detecção de Replay ​

Se a mesma chave for usada com o mesmo payload, o backend reconhece como replay e retorna a resposta original — sem reexecutar a operação. Isso é seguro porque o payload é idêntico. O replay preserva os headers da resposta original (ex.: Location), não apenas o corpo.

Rejeição de Reuso (409) ​

Se a mesma chave for usada com um payload diferente, o backend rejeita com 409 Conflict:

json
{
  "error": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
  "message": "Idempotency key already used with a different payload"
}

Isso impede que um cliente reutilize acidentalmente uma chave para operações diferentes. Durante a execução concorrente da mesma chave, retorna 409 IDEMPOTENCY_IN_PROGRESS (retryable) até o vencedor terminar.

Concorrência (claim atômico) ​

A reivindicação da chave é atômica via INSERT ... ON CONFLICT DO NOTHING (SQLite ≥ 3.24.0, suportado pelo D1):

typescript
// Dentro de idempotentMutation
const claim = await db
  .prepare(
    "INSERT INTO idempotency_ledger(key, user_id, payload_hash, status_code, response_body, response_headers, created_at) VALUES(?1, ?2, ?3, 0, '{}', '[]', ?4) ON CONFLICT(key, user_id) DO NOTHING",
  )
  .bind(idempotencyKey, userId, payloadHashValue, nowISO())
  .run();

if (claim.meta.changes === 0) {
  // Perdeu a corrida → re-leitura + replay (ou 409)
}

A operação de negócio e a gravação do resultado no ledger são atômicas via D1.batch() (disponível a partir do D1 v0.26.0): ou tudo persiste (negócio + ledger) ou nada persiste. O MutationExecutor pode retornar statements D1 adicionais (ex: INSERTs, UPDATEs) incluídos no mesmo batch().

Retenção ​

Entradas do ledger são retidas por 24 horas e purgadas periodicamente pelo cron (scheduled handler, purgeExpiredIdempotencyKeys). Chaves reutilizadas após a purga são tratadas como novas requisições (mesmo comportamento do Stripe).

Hash de Payload ​

A função payloadHash() em workers/src/modules/idempotency.ts gera um hash SHA-256 determinístico do payload:

typescript
async function payloadHash(payload: unknown): Promise<string>;

O payload é serializado como JSON canônico (ordenação estável de chaves) antes do hash, garantindo que dois payloads semanticamente idênticos produzam o mesmo hash independentemente da ordem das chaves no objeto original.

Uso no Frontend ​

No frontend, o EventWriter gera uma chave de idempotência (UUID v4) para cada evento e a armazena na entrada da syncQueue. Quando o syncQueueRepository drena a fila, cada requisição POST /api/v1/sync/events inclui o header Idempotency-Key correspondente.


Fonte: workers/CONTEXT.md + queries.ts

Distribuído sob licença MIT.