Anúncios incomodam? Ir Sem anúncios Hoje

Idempotência em APIs — Por que seu POST foi disparado duas vezes e como corrigir isso

Atualizado em

Um pagamento cobrado duas vezes, um pedido duplicado ou um usuário criado três vezes — esses são sintomas do mesmo contrato faltante em APIs. Aqui está o que significa idempotência, por que o POST quebra esse conceito e como as chaves de idempotência resolvem isso.

Idempotência em APIs — Por que seu POST foi executado duas vezes e como corrigi-lo 1
ANUNCIADO Remover?

O usuário clicou em "Pagar Agora". O spinner girou. Timeout de rede. O seu mecanismo de retentativa foi ativado. A cobrança foi processada. Em seguida, a solicitação original também foi concluída no lado do servidor — e a cobrança foi processada novamente. $200 retirado da conta do cliente, e uma solicitação de suporte esperando por você pela manhã.

Isso não é um caso raro. É o que acontece quando você não pensa em idempotência antes de precisar dela. Vamos corrigir isso.

O que a idempotência realmente significa

A palavra vem da matemática. Uma operação é idempotente se aplicá-la várias vezes produzir o mesmo resultado que aplicá-la uma vez. f(f(x)) = f(x).

No contexto de APIs: chamar o mesmo endpoint com a mesma intenção N vezes deve deixar o sistema na mesma condição que chamar o endpoint uma vez. A resposta pode ser um resultado armazenado, mas os efeitos colaterais — as gravações no banco de dados, as cobranças, os e-mails — devem ocorrer apenas uma vez.

É uma garantia sobre o que seu servidor não, não apenas sobre o que ele retorna.

Métodos HTTP: quem está seguro e quem não

O especificação HTTP designa certos métodos como idempotentes por definição. Aqui está a análise prática:

MétodoIdempotente?Por que
GET✅ SimLeitura apenas. Sem efeitos colaterais por definição.
HEAD✅ SimIgual ao GET, sem corpo retornado.
PUT✅ Sim"Defina este recurso exatamente nesse estado". Chamar duas vezes produz o mesmo resultado.
DELETE✅ SimO recurso é removido após a primeira chamada. Chamadas subsequentes não encontram nada para remover. (O código de status pode variar — 204 vs 404 — mas o estado do servidor não muda.)
POST❌ Não"Processar este payload". O que isso significa é dependente do servidor. Duas chamadas POST geralmente criam dois recursos ou acionam dois efeitos colaterais.
PATCH⚠️ DependeUma atualização relativa ("increment count by 1") não é idempotente. Uma atualização absoluta ("set count to 5") é. A implementação determina qual.

O problema é que a maioria das operações comerciais reais — cobrar um pagamento, criar uma ordem, enviar uma notificação — mapeia naturalmente para POST. E o POST não oferece garantias de idempotência por padrão.

A sequência que te leva

Aqui está o modo clássico de falha, passo a passo:

Client                    Network                   Server
  |
  |--- POST /payments ------>|                         |
  |                          |--- (delivered) -------->|
  |                          |                  processing...
  |                          |                  card charged ✓
  |<-- (connection drops) ---|                  response queued
  |
  |  [retry logic kicks in]
  |
  |--- POST /payments ------>|                         |
  |                          |--- (delivered) -------->|
  |                          |                  processing...
  |                          |                  card charged ✓ (again)
  |<------- 200 OK ----------|<------------------------|
  |
  [client sees: one charge. server did: two charges]

O cliente nunca viu a primeira resposta. Do ponto de vista do cliente, a solicitação falhou. Do ponto de vista do servidor, ela foi bem-sucedida duas vezes. Isso é um clássico dos sistemas distribuídos — o cliente e o servidor divergem sobre o que aconteceu.

Isso não é apenas para processadores de pagamentos. É para criação de pedidos, registro de usuários, envio de e-mails, reservas de estoque — qualquer coisa onde "executar duas vezes" tem consequências reais.

Chaves de idempotência: o padrão do Stripe

O Stripe popularizou a abordagem que a maioria dos APIs de pagamentos agora usa. O cliente gera uma chave única antes de enviar a solicitação e a anexa como um cabeçalho. O servidor usa essa chave para deduplicar.

Lado do cliente:

// Generate once, before any retry loop
const idempotencyKey = crypto.randomUUID();

async function chargeWithRetry(amount, retries = 3) {
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      const response = await fetch('/api/payments', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey, // same key on every retry
        },
        body: JSON.stringify({ amount, currency: 'usd' }),
      });

      if (response.ok) return await response.json();

      // Don't retry on client errors (4xx)
      if (response.status < 500) throw new Error(`Client error: ${response.status}`);

    } catch (err) {
      if (attempt === retries - 1) throw err;
      await sleep(Math.pow(2, attempt) * 1000); // exponential backoff
    }
  }
}

A chave deve ser gerada antes em qualquer loop de retenção — isso é o ponto central. Se você gerar um novo UUID em cada tentativa, você terá destruído completamente o mecanismo.

Um UUID v4 funciona bem aqui. Se você precisar gerar um rapidamente enquanto testa, IO Tools' gerador de UUID você pode produzir um lote deles sem precisar carregar uma biblioteca.

Lado do servidor: armazene a resposta e retorne-a em repetições

O trabalho do servidor é conceitualmente simples: verifique se essa chave já foi vista antes; se sim, retorne a resposta armazenada; se não, processe e armazene.

// Express + Redis
app.post('/api/payments', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];

  if (idempotencyKey) {
    const cached = await redis.get(`idem:${idempotencyKey}`);
    if (cached) {
      const { status, body } = JSON.parse(cached);
      return res.status(status).json(body);
    }
  }

  const result = await paymentProcessor.charge(req.body);
  const responseBody = { id: result.id, status: result.status };

  if (idempotencyKey) {
    // 24-hour TTL — same window Stripe uses
    await redis.setex(
      `idem:${idempotencyKey}`,
      86400,
      JSON.stringify({ status: 200, body: responseBody })
    );
  }

  res.json(responseBody);
});

Alguns detalhes que importam:

  • Falhas de cache, não apenas sucessos. Se a cobrança falhar (cartão recusado), armazene também a resposta de falha. Caso contrário, uma tentativa de retenção tentará a cobrança novamente, mesmo que já tenha retornado um erro — o que é exatamente o que você está tentando evitar.
  • Valide a consistência do corpo. O Stripe retorna 422 se a mesma chave for reutilizada com parâmetros diferentes de solicitação. Isso detecta erros onde você reutiliza acidentalmente uma chave em operações diferentes.
  • Trate duplicatas em andamento. Dois pedidos com a mesma chave chegando simultaneamente precisam de coordenação — processe um e retorne 409 na outra, ou use um bloqueio distribuído. O padrão SETNX do Redis é comum aqui.
  • Defina um TTL razoável. Após a expiração, as chaves podem ser recicladas e pedidos com a mesma chave são tratados como novos. Não armazene-as para sempre — seu cache irá se expandir.

Erros do lado do cliente que enviam POST duas vezes sem retenção

As chaves de idempotência protegem contra retenções na camada de rede. Elas não ajudam quando o cliente envia duas solicitações separadas e independentes. Culpritos comuns:

  • Clique duplo no botão de envio. O usuário clica, vê nada acontecer, clica novamente. Desative o botão imediatamente na primeira cliques — não após a resposta chegar. A diferença entre o clique e a resposta é exatamente quando o segundo clique ocorre.
  • Invocação dupla no React StrictMode. O React 18 Strict Mode executa efeitos duas vezes no desenvolvimento para detectar erros. Se você estiver enviando um POST em um useEffect sem limpeza, você verá solicitações duplicadas no desenvolvimento. Isso não acontece na produção, mas pode mascarar o problema real.
  • Resubmissão de formulário no navegador. Enviar → navegar → voltar → avançar. Alguns navegadores pedem "resubmeter?", outros simplesmente o fazem. O padrão POST-Redirect-GET (retornando um redirecionamento após um POST) elimina isso completamente.
  • Condições de corrida em manipuladores de eventos. Um clique e uma rápida pressão de Enter acionam o manipulador de envio antes que a primeira resposta chegue e desative o formulário.
  • Reenvio em segundo plano em aplicativos móveis. O iOS e o Android têm mecanismos de busca em segundo plano ou retenção de rede que podem repetir uma solicitação que o app já enviou. Gerar chaves de idempotência no momento da intenção do usuário, armazená-las localmente se necessário, e limpá-las apenas após confirmação de sucesso.

Uma alternativa digna de consideração: PUT para uma URL com UUID

Se você controla ambos os lados da API, há uma opção estruturalmente mais limpa para a criação de recursos: deixe o cliente atribuir o ID do recurso e use PUT em vez de POST.

# Instead of this (POST, not idempotent):
POST /orders
{"amount": 99.00, "items": [...]}

# Do this (PUT to client-generated UUID, idempotent by spec):
PUT /orders/7f3b9c2a-4e5d-4f8b-9a1c-2d3e4f5a6b7c
{"amount": 99.00, "items": [...]}

O PUT é idempotente por especificação HTTP — "defina este recurso nesse estado" significa que repetir o mesmo PUT não tem efeito adicional uma vez que o recurso já exista. O servidor trata a duplicata com INSERT ... ON CONFLICT DO NOTHING ou equivalente.

Esse padrão funciona bem para criação de pedidos, gerenciamento de rascunhos e qualquer recurso onde a consistência do ID em tentativas é importante. Ele não funciona quando um terceiro (um processador de pagamentos) atribui o ID ou quando o efeito colateral precisa acontecer no lado do servidor antes que você saiba o ID.

O que a implementação real do Stripe parece

O documento de chaves de idempotência vale a pena ler com atenção. Alguns pontos específicos que importam na prática:

  • Formato da chave: Qualquer string até 255 caracteres, limitada ao seu conta do Stripe. Duas contas diferentes usando a mesma string não se interferem entre si.
  • Janela de 24 horas: As chaves expiram após 24 horas. Uma tentativa após a expiração é tratada como uma solicitação nova — útil para saber se você está construindo fluxos de trabalho longos.
  • Diferença no corpo = 422: Mesma chave, parâmetros diferentes → o Stripe retorna 422 Unprocessable Entity. Esse é o comportamento correto; ele detecta o erro onde você reutiliza acidentalmente uma chave.
  • Deduplação concorrente: Se dois pedidos com a mesma chave chegarem simultaneamente, o Stripe processa um e retorna 409 Conflict no outro. Tente novamente o 409 após um pequeno atraso.
  • Falhas armazenadas no cache: Se a cobrança falhar (cartão recusado), o Stripe armazena essa falha. Tentar novamente com a mesma chave retorna a mesma recusa. Você precisa de uma nova chave para tentar um cartão diferente — o que é correto, porque a tentativa anterior foi uma operação completa e intencional.

Testando idempotência sem uma suite de testes de integração completa

A forma mais rápida de verificar o comportamento é enviar seu endpoint duas vezes com a mesma chave e verificar que a segunda chamada retorna a resposta armazenada sem acionar novamente o efeito colateral. IO Tools' construtor de comando cURL facilita a construção da solicitação com cabeçalhos personalizados — incluindo Idempotency-Key — sem precisar memorizar a sintaxe dos flags do cURL.

Cenários a cobrir:

  • Mesma chave + mesmo corpo, dentro do TTL → resposta armazenada retornada, efeito colateral acionado uma vez
  • Mesma chave + corpo diferente → 422 (ou seu status de conflito escolhido)
  • Mesma chave após expiração do TTL → tratada como uma solicitação nova
  • Sem chave fornecida → processada normalmente (decida antecipadamente se as chaves são obrigatórias ou opcionais)
  • Dois pedidos concorrentes com a mesma chave → um processa, outro recebe 409

A versão curta

O POST não é idempotente, e essa brecha entre "solicitação enviada" e "resposta recebida" é onde os efeitos colaterais duplicados vivem. A solução não é complicada: gere um UUID antes de qualquer loop de retenção, envie como cabeçalho, armazene a resposta no lado do servidor, indexada por esse cabeçalho. As partes difíceis são os detalhes — armazenamento de falhas, tratamento de duplicatas concorrentes, escolha do TTL — mas o padrão central é simples o suficiente para ser adicionado a qualquer endpoint que importe.

Adicione suporte a chaves de idempotência antes que a primeira tráfego de produção chegue. Retrofitar isso após um incidente de cobrança dupla é um momento significativamente pior para aprender a lição.

Quer eliminar anúncios? Fique sem anúncios hoje mesmo

Instale nossas extensões

Adicione ferramentas de IO ao seu navegador favorito para acesso instantâneo e pesquisa mais rápida

Ao Extensão do Chrome Ao Extensão de Borda Ao Extensão Firefox Ao Extensão Opera

O placar chegou!

Placar é uma forma divertida de acompanhar seus jogos, todos os dados são armazenados em seu navegador. Mais recursos serão lançados em breve!

ANUNCIADO Remover?
ANUNCIADO Remover?
ANUNCIADO Remover?

Notícias com destaques técnicos

Envolver-se

Ajude-nos a continuar fornecendo ferramentas gratuitas valiosas

Compre-me um café
ANUNCIADO Remover?