Chaves de Idempotência e Distributed Locks: Como Evitar Cobranças Duplas

Chaves de Idempotência e Distributed Locks: Como Evitar Cobranças Duplas

O Pesadelo do Duplo Clique: Como a Rede Móvel Duplica Pagamentos

Mais cedo ou mais tarde, quem desenvolve lojas online ou plataformas SaaS recebe um alerta desesperado do suporte: “O cliente carregou apenas uma vez no botão de pagar, mas a conta do banco dele tem duas cobranças de 350 euros!”

Na maioria das vezes, o teu código não tem nenhum erro óbvio de programação. O problema acontece porque as redes móveis e os browsers não funcionam num mundo ideal.

Repara neste cenário real:

  1. O utilizador clica em “Pagar Agora” no telemóvel enquanto viaja de comboio.
  2. O browser dele envia um pedido POST /api/pagamentos com os dados da encomenda.
  3. O teu servidor contacta o gateway de pagamentos (como o Stripe ou Easypay) e o dinheiro é retirado com sucesso.
  4. Quando o teu servidor tenta devolver o código 200 OK, o telemóvel do cliente perde cobertura durante dois segundos e a ligação TCP cai.
  5. O browser deteta a falha de ligação e reenvia o pedido automaticamente. Ou então o cliente, impaciente por não ver confirmação, volta a clicar furiosamente no botão.
  6. A tua API recebe o segundo pedido, acha que é uma nova compra e debita o cartão de crédito do cliente pela segunda vez.

O ERRO DA COBRANÇA DUPLA NUMA API TRADICIONAL

  • Telemóvel do Cliente (Rede Instável)
  • Servidor API
  • POST /api/pagamentos (1º Clique) → (Cobra no Stripe) (Cartão Cobrado 350€!)
  • Ligação Cai: Mudança de Antena 4G: X — (Cliente não vê nada!)
  • (O browser reenvia ou o utilizador clica de novo)
  • POST /api/pagamentos (2º Pedido Duplicado) → (Cobra NOVAMENTE!)
  • 200 OK (Pagamento Confirmado) — (Cobrado 350€ OUTRA VEZ)
  • O cliente vê apenas uma encomenda, mas o banco retirou-lhe 700 euros!

Para desenhares web apps profissionais, tens de garantir que os teus endpoints críticos são estritamente idempotentes: uma operação que, mesmo executada dez vezes com os mesmos parâmetros, deixa o sistema exatamente no mesmo estado final que deixaria se tivesse sido chamada uma única vez.

Anatomia da Idempotência: Chaves Únicas, Estados e Bloqueios Atómicos

Um sistema de pagamentos robusto apoia-se em três pilares fundamentais:

  1. Chave de Idempotência do Cliente: Antes de disparar o pedido, o teu frontend gera um identificador único (UUIDv4) e envia-o num cabeçalho HTTP: Idempotency-Key: a48f12cd-8891-4509-90b1-c89b702910a3.
  2. Bloqueio Distribuído Atómico (Distributed Lock): Antes de cobrar o cartão, o servidor tenta reservar essa chave numa base de dados em memória ultrarrápida como o Redis. Se outro processo já estiver a correr com essa chave, o pedido repetido é travado de imediato com 409 Conflict.
  3. Persistência de Resultados: Quando o pagamento é concluído, o código de resposta HTTP e os dados da transação são guardados no PostgreSQL durante 24 a 48 horas associados àquela chave.

FLUXO DE VIDA DE UM PEDIDO IDEMPOTENTE

  • Pedido Recebido com Cabeçalho: Idempotency-Key:
  • Verifica no PostgreSQL ou Redis se a chave já existe: Existe e Estado == “CONCLUIDO”?
  • DEVOLVE A RESPOSTA EM CACHE (Zero cobranças adicionais!)
  • Existe e Estado == “A_PROCESSAR”?
  • DEVOLVE HTTP 409 CONFLICT (“Operação em curso”)
  • Não Existe?
    1. ADQUIRE LOCK ATÓMICO (Redis SETNX)
    1. REGISTA Estado = “A_PROCESSAR”
    1. Executa a cobrança no fornecedor de pagamentos
    1. ATUALIZA Estado = “CONCLUIDO” e guarda a resposta JSON
    1. LIBERTA O LOCK ATÓMICO
    1. Devolve HTTP 200 OK original

Com este fluxo, aplicas o princípio de anti-over-engineering: evitas dores de cabeça e garantes segurança bancária com uma arquitetura elegante e fiável.

Como Implementar Locks Atómicos no Redis e Cache em PostgreSQL com Node.js

Vejamos como podes implementar esta lógica de forma concreta em Node.js com Redis e PostgreSQL. Usamos o comando atómico SETNX no Redis para o bloqueio de concorrência e o PostgreSQL para arquivo duradouro.

Primeiro, o módulo do bloqueio atómico:

// src/services/lockDistribuido.ts
import Redis from 'ioredis';
import crypto from 'node:crypto';

const redis = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');

export async function adquirirLock(
  chaveRecurso: string,
  ttlMs = 10000
): Promise<{ adquirido: boolean; libertar: () => Promise<void> }> {
  const tokenLock = crypto.randomUUID();
  const chaveRedis = `lock:idempotencia:${chaveRecurso}`;

  // Operação atómica: grava APENAS SE NÃO EXISTIR com expiração em ms
  const resultado = await redis.set(chaveRedis, tokenLock, 'PX', ttlMs, 'NX');

  if (resultado !== 'OK') {
    return { adquirido: false, libertar: async () => {} };
  }

  // Libertação segura através de script Lua para evitar apagar locks de outros
  const libertar = async () => {
    const scriptLua = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      else
        return 0
      end
    `;
    await redis.eval(scriptLua, 1, chaveRedis, tokenLock);
  };

  return { adquirido: true, libertar };
}

Em seguida, o middleware que processa as requisições na tua API:

// src/middleware/idempotencia.ts
import { Request, Response, NextFunction } from 'express';
import { pool } from '../db/postgres.js';
import { adquirirLock } from '../services/lockDistribuido.js';

export async function middlewareIdempotencia(req: Request, res: Response, next: NextFunction) {
  const chave = req.headers['idempotency-key'] as string;
  if (!chave) return next();

  // Passo 1: Consulta o PostgreSQL para ver se a operação já terminou
  const registo = await pool.query(
    'SELECT estado, codigo_status, resposta_json FROM chaves_idempotencia WHERE chave = $1',
    [chave]
  );

  if (registo.rows.length > 0) {
    const dados = registo.rows[0];
    if (dados.estado === 'CONCLUIDO') {
      return res.status(dados.codigo_status).json(dados.resposta_json);
    }
    if (dados.estado === 'A_PROCESSAR') {
      return res.status(409).json({ erro: 'Transação em processamento. Aguarda alguns segundos.' });
    }
  }

  // Passo 2: Reserva o lock no Redis
  const { adquirido, libertar } = await adquirirLock(chave);
  if (!adquirido) {
    return res.status(409).json({ erro: 'Pedido concorrente duplicado em execução.' });
  }

  await pool.query(
    'INSERT INTO chaves_idempotencia (chave, estado, criado_em) VALUES ($1, $2, NOW()) ON CONFLICT (chave) DO NOTHING',
    [chave, 'A_PROCESSAR']
  );

  // Captura a resposta final para arquivamento
  const respostaOriginal = res.json.bind(res);
  res.json = (corpo: any) => {
    pool.query(
      'UPDATE chaves_idempotencia SET estado = $1, codigo_status = $2, resposta_json = $3, concluido_em = NOW() WHERE chave = $4',
      ['CONCLUIDO', res.statusCode, corpo, chave]
    ).finally(() => libertar());

    return respostaOriginal(corpo);
  };

  next();
}

Ao isolares estas dependências com Docker, todo o ambiente de testes reflete exatamente o comportamento dos teus servidores finais.

Estrutura de Base de Dados e Proteção Contra Alteração de Dados

No PostgreSQL, a tua tabela de idempotência deve validar não só a chave, mas também o conteúdo do pedido:

CREATE TABLE chaves_idempotencia (
    chave VARCHAR(255) PRIMARY KEY,
    utilizador_id UUID NOT NULL,
    caminho_pedido VARCHAR(255) NOT NULL,
    hash_parametros CHAR(64) NOT NULL, -- Hash SHA-256 dos dados enviados
    estado VARCHAR(32) NOT NULL,        -- 'A_PROCESSAR', 'CONCLUIDO', 'FALHOU'
    codigo_status INTEGER,
    resposta_json JSONB,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    concluido_em TIMESTAMPTZ
);

CREATE INDEX idx_chaves_idempotencia_criado_em ON chaves_idempotencia (criado_em);

Repara na importância do campo hash_parametros. Se um cliente com problemas de rede enviar a mesma Idempotency-Key, mas o valor da compra for alterado de 20€ para 500€, a tua API calcula o hash SHA-256 do corpo do pedido e deteta a divergência. Nesse caso, rejeitas a requisição com 422 Unprocessable Entity para impedir fraudes ou manipulação acidental. Podes analisar padrões complementares deste método no GitHub.

Boas Práticas Operacionais: Prazos de Expiração e Respostas a Erros

Para manteres a consistência financeira dos teus websites de alta performance segundo as regras operacionais da WebXtek, aplica estas recomendações:

  1. Define Prazos de Expiração Razoáveis: Não guardes chaves de idempotência para sempre. Um prazo entre 24 e 48 horas é suficiente para cobrir repetições de rede. Podes limpar registos antigos com uma tarefa agendada no cron.
  2. Usa Jitter Aleatório no Frontend: Se o frontend receber um 409 Conflict, não deve tentar de novo de imediato. Programa uma espera exponencial com variação aleatória (jitter) entre 300ms e 1500ms para evitar sobrecarregar a base de dados.
  3. Guarda Também as Falhas de Saldo: Se o cartão do cliente for recusado por falta de saldo, isso é uma resposta definitiva. Deves arquivar o erro 402 Payment Required associado à chave, para que cliques repetidos não continuem a bater na API do banco nem bloqueiem a conta do cliente por suspeita de fraude.

Com a combinação de chaves de idempotência e locks atómicos no Redis, eliminas em definitivo o receio de cobranças duplicadas e garantes a máxima fiabilidade financeira na tua aplicação.

Na prática, aplicas exatamente estes padrões operacionais em setores exigentes como saas empresarial b2b e especialistas em devops e arquitetura cloud. Quando alinhas a tua estrutura através de web apps e websites de alta performance, transformas boas práticas técnicas numa vantagem competitiva real e mensurável para o teu negócio.

[+] [ INDEX_DIRECTORY ] 13 tópicos ▼
[ SYSTEM.FAQ ]

Perguntas Frequentes

O que é uma chave de idempotência numa API de pagamentos?

Uma chave de idempotência é um identificador único gerado pelo cliente (normalmente um UUIDv4) enviado num cabeçalho HTTP (Idempotency-Key). O teu servidor usa essa chave para identificar repetições do mesmo pedido, garantindo que pagamentos repetidos devolvem o mesmo resultado sem voltar a cobrar o cartão.

Qual é a diferença entre uma chave de idempotência e um distributed lock?

A chave de idempotência guarda o histórico das operações e devolve a resposta gravada quando a operação já terminou. O distributed lock (como o SETNX no Redis) garante exclusão mútua em tempo real, impedindo que dois pedidos concorrentes com a mesma chave sejam executados em paralelo.

Como é que o comando SETNX do Redis garante atomicidade em concorrência?

O comando SET lock_key token NX PX 10000 grava o registo apenas se ele não existir previamente (NX) e define um prazo de expiração (PX). Como o Redis processa pedidos sequencialmente num ciclo único, a operação é matematicamente atómica e livre de race conditions.

O que deve responder a tua API se chegar um pedido duplicado enquanto o primeiro ainda está a processar?

Se chegar um segundo pedido idêntico com o lock ainda ocupado, a API deve responder com o código HTTP 409 Conflict indicando que a transação está em processamento, ou aguardar a conclusão para devolver o resultado final sem cobrar novamente.

> INICIAR_PROJETO

Precisa de um website que transmita confiança, apareça na pesquisa e dê mais força à sua presença digital? Comece a conversa aqui.