Programação Por Equipe DevBras

Capa do artigo: Pagar duas vezes porque a tela piscou: idempotência no pagamento

Pagar duas vezes porque a tela piscou: idempotência no pagamento

Como usar uma chave de idempotência para que a segunda tentativa de um pagamento devolva o resultado da primeira, em vez de cobrar de novo.

A pessoa clicou em "Pagar", a rede do celular caiu na volta da resposta e a tela ficou girando. Ela clicou de novo. No extrato, duas cobranças. O servidor fez exatamente o que foi pedido, duas vezes. A resposta curta: cada operação que só pode acontecer uma vez precisa de uma chave de idempotência, gerada no cliente antes do primeiro envio e repetida em toda nova tentativa. O servidor grava a chave junto com o resultado. Se a mesma chave chegar de novo, ele devolve o resultado guardado e não cobra outra vez.

Desabilitar o botão depois do clique ajuda, mas é conforto de interface. Não protege contra reenvio automático, aba duplicada, retry do aplicativo ou proxy que repete a requisição. A garantia tem de morar no servidor.

Por que um timeout não significa que não cobrou

Quando o cliente desiste de esperar, ele não sabe em que ponto a requisição parou. Existem pelo menos três cenários que, do lado de quem clicou, parecem iguais:

  • A requisição nunca chegou ao servidor. Nada aconteceu.
  • Chegou, o servidor está processando e ainda vai cobrar.
  • Chegou, o servidor cobrou, e a resposta se perdeu no caminho de volta.

Repetir a requisição é a reação certa no primeiro caso e um desastre nos outros dois. Sem um identificador comum, o servidor não tem como distinguir "pedido novo" de "o mesmo pedido de novo". O método POST, pela especificação do HTTP, não é idempotente: duas chamadas iguais podem criar dois recursos. Idempotência é uma propriedade que você constrói em cima dele.

Os grandes processadores de pagamento já aceitam um cabeçalho para isso, em geral chamado Idempotency-Key, e existe um rascunho de padronização desse cabeçalho na IETF. O nome não importa tanto quanto a regra: a chave identifica a intenção, não a requisição.

Como implementar a chave de idempotência

  1. No cliente, gere um identificador único (um UUID) quando a pessoa inicia o pagamento, antes do primeiro envio. Guarde-o enquanto essa intenção existir, inclusive se a página recarregar.
  2. Envie a chave num cabeçalho em toda tentativa daquele mesmo pagamento.
  3. No servidor, tente gravar a chave numa tabela com restrição de unicidade, já marcada como "processando". O banco decide quem chegou primeiro.
  4. Se a gravação falhar porque a chave já existe, não processe. Devolva o resultado salvo, ou avise que ainda está em andamento.
  5. Ao terminar, salve o código HTTP e o corpo da resposta junto com a chave.
  6. Repasse a mesma chave ao provedor de pagamento, se ele aceitar. Assim a proteção vale também entre você e ele.
  7. Guarde também um hash do corpo do pedido. Mesma chave com conteúdo diferente é erro do cliente, não repetição.

A tabela

Exemplo em PostgreSQL. Em sistema com várias contas, inclua o identificador do cliente na chave primária, para que a chave de uma pessoa nunca colida com a de outra.

CREATE TABLE idempotencia (
  chave        text PRIMARY KEY,
  hash_pedido  text NOT NULL,
  status       text NOT NULL,
  codigo_http  integer,
  resposta     jsonb,
  criado_em    timestamptz NOT NULL DEFAULT now()
);

O servidor em Node.js com Express e pg

const express = require('express');
const crypto = require('crypto');
const { Pool } = require('pg');
const { cobrar } = require('./gateway');

const pool = new Pool();
const app = express();
app.use(express.json());

// Express 5 repassa ao tratador de erros a exceção de um handler async.
// No Express 4, envolva o handler num try/catch ou numa função que chame next(erro).
app.post('/pagamentos', async (req, res) => {
  const chave = req.get('Idempotency-Key');
  if (!chave) {
    return res.status(400).json({ erro: 'Envie o cabeçalho Idempotency-Key' });
  }
  // Campos em ordem fixa: o hash não muda se o cliente reordenar o JSON.
  const { pedidoId, valorCentavos, moeda } = req.body;
  const pedido = { pedidoId, valorCentavos, moeda };
  const hash = crypto.createHash('sha256')
    .update(JSON.stringify([pedidoId, valorCentavos, moeda]))
    .digest('hex');

  const novo = await pool.query(
    "INSERT INTO idempotencia (chave, hash_pedido, status) VALUES ($1, $2, 'processando') ON CONFLICT (chave) DO NOTHING",
    [chave, hash]
  );

  if (novo.rowCount === 0) {
    const { rows } = await pool.query(
      'SELECT hash_pedido, status, codigo_http, resposta FROM idempotencia WHERE chave = $1',
      [chave]
    );
    const anterior = rows[0];
    if (anterior.hash_pedido !== hash) {
      return res.status(422).json({ erro: 'Chave já usada com outro pedido' });
    }
    if (anterior.status === 'processando') {
      return res.status(409).json({ erro: 'Pagamento em andamento, consulte em instantes' });
    }
    return res.status(anterior.codigo_http).json(anterior.resposta);
  }

  try {
    const resultado = await cobrar(pedido, chave);
    await pool.query(
      "UPDATE idempotencia SET status = 'concluido', codigo_http = 201, resposta = $2 WHERE chave = $1",
      [chave, resultado]
    );
    return res.status(201).json(resultado);
  } catch (erro) {
    if (erro.recusado) {
      const corpo = { erro: 'Pagamento recusado' };
      await pool.query(
        "UPDATE idempotencia SET status = 'concluido', codigo_http = 402, resposta = $2 WHERE chave = $1",
        [chave, corpo]
      );
      return res.status(402).json(corpo);
    }
    return res.status(503).json({ erro: 'Resultado incerto, consulte o status do pagamento' });
  }
});

app.listen(3000);

O módulo ./gateway é a sua integração com o provedor: a função cobrar chama a API dele repassando a chave e lança um erro com recusado: true quando o provedor responde que o cartão foi negado. Repare no que o código faz com o erro desconhecido, como um timeout com o provedor: ele não apaga a chave nem marca como falha. O pagamento pode ter acontecido. A linha fica em "processando" até uma rotina de conciliação consultar o provedor, ou até o webhook dele confirmar o resultado. Se você tratar timeout como "não cobrou" e liberar a chave, volta a ter cobrança dupla, só que com mais passos. Para o webhook, veja como validar o webhook com segredo.

O cliente

const chavePagamento = crypto.randomUUID();

async function pagar(dados) {
  const resposta = await fetch('/pagamentos', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': chavePagamento
    },
    body: JSON.stringify(dados),
    signal: AbortSignal.timeout(15000)
  });
  return resposta.json();
}

A chave nasce uma vez, fora da função. Cada chamada a pagar reusa a mesma. Uma nova chave só deve ser gerada quando a pessoa realmente começa outro pagamento, por exemplo depois de alterar o valor ou o carrinho. Prazo de espera no cliente é tema de outro guia: toda chamada externa precisa de um prazo para desistir.

Erros comuns

  • Gerar a chave dentro da função de envio. Cada tentativa ganha uma chave nova e o servidor trata cada uma como pagamento diferente. A proteção existe só no papel.
  • Verificar se a chave existe com SELECT e depois inserir. Duas requisições simultâneas passam pela verificação antes de qualquer uma gravar. Use a restrição de unicidade do banco como árbitro.
  • Apagar a chave quando a chamada ao provedor dá timeout. O provedor pode ter cobrado. A próxima tentativa cobra de novo.
  • Usar o hash do corpo como chave. Duas compras legítimas e idênticas, como dois cafés seguidos, viram uma só. A chave identifica a intenção, não o conteúdo.
  • Confiar só no botão desabilitado. Recarregar a página, abrir em outra aba ou um retry automático da biblioteca passam por cima.
  • Não definir validade para as chaves. A tabela cresce para sempre. Combine um prazo de retenção maior que o maior intervalo realista de nova tentativa e limpe o que passou dele.

Checklist

  • A chave é gerada uma vez por intenção de pagamento, antes do primeiro envio.
  • O servidor recusa pedido de pagamento sem chave.
  • A unicidade é garantida por restrição no banco, não por consulta prévia.
  • Repetir a mesma requisição devolve o mesmo código HTTP e o mesmo corpo da primeira.
  • Mesma chave com corpo diferente devolve erro.
  • Timeout com o provedor deixa o pagamento como incerto e dispara conciliação.
  • A chave é repassada ao provedor, quando ele aceita.
  • Os valores trafegam como inteiros em centavos, como em dinheiro não mora em ponto flutuante.

Faça o teste no seu ambiente de homologação: dispare a mesma requisição de pagamento duas vezes, com um segundo de intervalo, como se a primeira resposta tivesse se perdido. A segunda chamada devolve o resultado da primeira, ou aparece uma segunda cobrança no painel do provedor?

Avalie este artigo

Referência: https://developer.mozilla.org/docs/Web/HTTP/Methods/POST