12 de mai. de 2025·8 min de leitura

Endpoints idempotentes em Go: chaves, tabelas de deduplicação, tentativas

Projete endpoints idempotentes em Go com chaves de idempotência, tabelas de dedup no PostgreSQL e handlers seguros para retentativas para pagamentos, imports e webhooks.

Endpoints idempotentes em Go: chaves, tabelas de deduplicação, tentativas

Por que retentativas criam duplicatas (e por que idempotência importa)

Retentativas acontecem mesmo quando nada está “errado”. Um cliente dá timeout enquanto o servidor ainda está processando. Uma conexão móvel cai e o app tenta novamente. Um job runner recebe um 502 e reenvia automaticamente a mesma requisição. Com entrega ao menos uma vez (comum em filas e webhooks), duplicatas são normais.

Por isso a idempotência importa: requisições repetidas devem levar ao mesmo resultado final que uma única requisição.

Alguns termos são fáceis de confundir:

  • Seguro (safe): chamar não altera o estado (como uma leitura).
  • Idempotente: chamar várias vezes tem o mesmo efeito que chamar uma vez.
  • At-least-once: o remetente reenvia até “grudar”, então o receptor deve lidar com duplicatas.

Sem idempotência, retentativas podem causar danos reais. Um endpoint de pagamento pode cobrar duas vezes se a primeira cobrança foi feita mas a resposta não chegou ao cliente. Um endpoint de import pode criar linhas duplicadas quando um worker reenvia após timeout. Um handler de webhook pode processar o mesmo evento duas vezes e enviar dois e-mails.

O ponto-chave: idempotência é um contrato da API, não um detalhe interno de implementação. Os clientes precisam saber o que podem re-tentar, qual chave enviar e que resposta esperar quando uma duplicata for detectada. Se você muda o comportamento silenciosamente, quebra a lógica de retentativa e cria novos modos de falha.

Idempotência também não substitui monitoramento e reconciliação. Acompanhe taxas de duplicatas, registre decisões de “replay” e compare periodicamente sistemas externos (como um provedor de pagamentos) com seu banco de dados.

Escolha o escopo e as regras de idempotência para cada endpoint

Antes de adicionar tabelas ou middleware, decida o que “mesma requisição” significa e o que seu servidor promete fazer quando um cliente re-tentar.

A maioria dos problemas aparece em POST porque ele frequentemente cria algo ou aciona um efeito lateral (cobrar um cartão, enviar uma mensagem, iniciar uma importação). PATCH também pode precisar de idempotência se acionar efeitos laterais, não apenas atualizar um campo simples. GET não deve alterar estado.

Defina o escopo: onde a chave é única

Escolha um escopo que corresponda às suas regras de negócio. Escopo muito amplo bloqueia trabalho válido. Escopo muito estreito permite duplicatas.

Escopos comuns:

  • Por endpoint + cliente
  • Por endpoint + objeto externo (por exemplo, invoice_id ou order_id)
  • Por endpoint + tenant (para sistemas multi-tenant)
  • Por endpoint + método de pagamento + valor (só se suas regras permitirem)

Exemplo: para um endpoint “Criar pagamento”, faça a chave única por cliente. Para “Ingerir evento de webhook”, escopo pelo ID do evento do provedor de pagamento (unicidade global fornecida pelo provedor).

Decida o que repetir em duplicatas

Quando uma duplicata chega, retorne o mesmo resultado da primeira tentativa bem-sucedida. Na prática, isso significa reproduzir o mesmo código de status HTTP e o mesmo corpo de resposta (ou ao menos o mesmo ID de recurso e estado).

Clientes dependem disso. Se a primeira tentativa teve sucesso mas a rede caiu, a retentativa não deve criar uma segunda cobrança ou um segundo job de import.

Escolha uma janela de retenção

As chaves devem expirar. Mantenha-as tempo suficiente para cobrir retentativas realistas e jobs atrasados.

  • Pagamentos: 24 a 72 horas é comum.
  • Imports: uma semana pode ser razoável se usuários re-tentarem mais tarde.
  • Webhooks: alinhe à política de reenvio do provedor.

Defina “mesma requisição”: chave explícita vs hash do corpo

Uma chave de idempotência explícita (header ou campo) é geralmente a regra mais limpa.

Um hash do corpo pode ajudar como plano B, mas falha facilmente com mudanças inofensivas (ordem de campos, espaços, timestamps). Se usar hashing, normalize a entrada e seja rígido sobre quais campos são incluídos.

Chaves de idempotência: como funcionam na prática

Uma chave de idempotência é um contrato simples entre cliente e servidor: “Se você ver essa chave de novo, trate como a mesma requisição.” É uma das ferramentas mais práticas para APIs tolerantes a retentativas.

A chave pode vir de qualquer dos lados, mas para a maioria das APIs ela deve ser gerada pelo cliente. O cliente sabe quando está re-tentando a mesma ação, então pode reutilizar a mesma chave nas tentativas. Chaves geradas pelo servidor ajudam quando você cria primeiro um recurso “rascunho” (como um job de import) e depois permite que clientes re-tentem referenciando esse ID de job, mas não ajudam na requisição inicial.

Use uma string aleatória e imprevisível. Mire em pelo menos 128 bits de aleatoriedade (por exemplo, 32 caracteres hex ou um UUID). Não construa chaves a partir de timestamps ou IDs de usuário.

No servidor, armazene a chave com contexto suficiente para detectar uso indevido e reproduzir o resultado original:

  • Quem fez a chamada (account ou user ID)
  • Qual endpoint ou operação se aplica
  • Um hash dos campos importantes da requisição
  • Status atual (em progresso, sucedido, falhado)
  • A resposta para reproduzir (código de status e corpo)

Uma chave deve ser escopada, tipicamente por usuário (ou por token de API) mais endpoint. Se a mesma chave for reutilizada com um payload diferente, rejeite com um erro claro. Isso previne colisões acidentais onde um cliente bugado envia um novo valor usando uma chave antiga.

No replay, retorne o mesmo resultado da primeira tentativa bem-sucedida. Isso significa o mesmo código de status HTTP e o mesmo corpo, não uma leitura fresca que pode ter mudado.

Tabelas de deduplicação no PostgreSQL: um padrão simples e confiável

Uma tabela dedicada de dedup é uma das maneiras mais simples de implementar idempotência. A primeira requisição cria uma linha para a chave de idempotência. Toda retentativa lê essa mesma linha e retorna o resultado armazenado.

O que armazenar

Mantenha a tabela pequena e focada. Uma estrutura comum:

  • key: a chave de idempotência (text)
  • owner: quem é dono da chave (user_id, account_id ou ID do cliente de API)
  • request_hash: um hash dos campos importantes da requisição
  • response: o payload final de resposta (frequentemente JSON) ou um ponteiro para um resultado armazenado
  • created_at: quando a chave foi vista pela primeira vez

A restrição única é o núcleo do padrão. Faça unicidade em (owner, key) para que um cliente não crie duplicatas e dois clientes diferentes não colidam.

Armazene também um request_hash para detectar uso indevido da chave. Se uma retentativa chegar com a mesma chave mas hash diferente, retorne um erro em vez de misturar duas operações diferentes.

Retenção e indexação

Linhas de dedup não deveriam viver para sempre. Mantenha-as o tempo necessário para cobrir janelas reais de retentativa e depois limpe-as.

Para desempenho sob carga:

  • Índice único em (owner, key) para inserção ou busca rápida
  • Índice opcional em created_at para tornar a limpeza barata

Se a resposta for grande, armazene um ponteiro (por exemplo, um result ID) e mantenha o payload completo em outro lugar. Isso reduz o crescimento da tabela enquanto preserva comportamento de retry consistente.

Passo a passo: fluxo de um handler tolerante a retentativas em Go

Design for change safely
Inclua idempotência no design da sua API desde o início e regenere código limpo conforme os requisitos mudam.
Comece a construir

Um handler tolerante a retentativas precisa de duas coisas: uma forma estável de identificar “a mesma requisição novamente” e um lugar durável para armazenar o primeiro resultado para que você possa reproduzi-lo.

Um fluxo prático para pagamentos, imports e ingestão de webhooks:

  1. Valide a requisição e derive três valores: uma chave de idempotência (de um header ou campo do cliente), um owner (tenant ou user ID) e um request hash (hash dos campos importantes).

  2. Inicie uma transação no banco e tente criar um registro de dedup. Faça-o único em (owner, key). Armazene request_hash, status (started, completed) e placeholders para a resposta.

  3. Se a inserção conflitar, carregue a linha existente. Se estiver completed, retorne a resposta armazenada. Se estiver started, espere brevemente (polling simples) ou retorne 409/202 para que o cliente re-tente mais tarde.

  4. Só quando você “possuir” com sucesso a linha de dedup, execute a lógica de negócio uma vez. Grave efeitos colaterais dentro da mesma transação quando possível. Persista o resultado do negócio mais a resposta HTTP (código e corpo).

  5. Faça commit e registre a chave de idempotência e owner para que o suporte possa rastrear duplicatas.

Um padrão mínimo de tabela:

create table idempotency_keys (
  owner_id text not null,
  idem_key text not null,
  request_hash text not null,
  status text not null,
  response_code int,
  response_body jsonb,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  primary key (owner_id, idem_key)
);

Exemplo: um endpoint “Criar payout” dá timeout após cobrar. O cliente re-tenta com a mesma chave. Seu handler encontra o conflito, vê um registro completed e retorna o ID do payout original sem cobrar novamente.

Pagamentos: cobrar exatamente uma vez, mesmo com timeouts

Pagamentos são onde idempotência deixa de ser opcional. Redes falham, apps móveis re-tentam e gateways às vezes dão timeout depois de já terem criado a cobrança.

Uma regra prática: a chave de idempotência protege a criação da cobrança, e o ID do provedor de pagamento (charge/intent ID) vira a fonte da verdade depois disso. Uma vez que você armazenou um provider ID, não crie uma nova cobrança para a mesma requisição.

Um padrão que lida com retentativas e incerteza do gateway:

  • Leia e valide a chave de idempotência.
  • Em uma transação de banco, crie ou busque uma linha de pagamento chaveada por (merchant_id, idempotency_key). Se já tiver provider_id, retorne o resultado salvo.
  • Se não houver provider_id, chame o gateway para criar um PaymentIntent/Charge.
  • Se o gateway for bem-sucedido, persista provider_id e marque o pagamento como “succeeded” (ou “requires_action”).
  • Se o gateway der timeout ou retornar um resultado desconhecido, armazene o status “pending” e retorne uma resposta consistente que diga ao cliente que é seguro re-tentar.

O detalhe-chave é como tratar timeouts: não assuma falha. Marque o pagamento como pendente e confirme consultando o gateway depois (ou via webhook) usando o provider ID quando o tiver.

Respostas de erro devem ser previsíveis. Clientes constroem lógica de retry com base no que você retorna, então mantenha códigos de status e formatos de erro estáveis.

Imports e endpoints em lote: dedup sem perder progresso

Model your dedup table
Crie uma tabela de deduplicação PostgreSQL no Data Designer e aplique chaves únicas.
Modelar dados

Imports são onde duplicatas mais atrapalham. Um usuário faz upload de um CSV, seu servidor dá timeout aos 95% e ele re-tenta. Sem um plano, você ou cria linhas duplicadas ou força o usuário a recomeçar.

Para trabalho em lote, pense em duas camadas: o job de import e os itens dentro dele. Idempotência ao nível de job impede que a mesma requisição crie múltiplos jobs. Idempotência ao nível do item impede que a mesma linha seja aplicada duas vezes.

Um padrão de nível de job é exigir uma chave de idempotência por requisição de import (ou derivar uma a partir de um hash estável da requisição mais o user ID). Armazene-a com um registro import_job e retorne o mesmo job ID em retentativas. O handler deve poder dizer “Já vi esse job, aqui está o estado atual”, em vez de “comece de novo”.

Para dedup ao nível de item, dependa de uma chave natural que já exista nos dados. Por exemplo, cada linha pode incluir um external_id do sistema de origem, ou uma combinação estável como (account_id, email). Imponha isso com uma restrição única no PostgreSQL e use upsert para que retentativas não criem duplicatas.

Antes de lançar, decida o que um replay faz quando uma linha já existe. Seja explícito: pular, atualizar campos específicos ou falhar. Evite “mesclar” a menos que tenha regras muito claras.

Sucesso parcial é normal. Em vez de retornar um grande “ok” ou “failed”, armazene resultados por linha ligados ao job: número da linha, chave natural, status (created, updated, skipped, error) e mensagem de erro. Em uma retentativa, você pode reexecutar com segurança mantendo os mesmos resultados para linhas já finalizadas.

Para tornar imports reiniciáveis, adicione checkpoints. Processe em páginas (por exemplo, 500 linhas por vez), armazene o cursor da última página processada (índice de linha ou cursor de origem) e atualize-o após cada commit de página. Se o processo cair, a próxima tentativa retoma do último checkpoint.

Ingestão de webhooks: dedup, valide e então processe com segurança

Implement the replay flow
Use o Business Process Editor para tratar caminhos em andamento, concluídos e de conflito.
Adicionar lógica

Remetentes de webhook re-tentam. Eles também enviam eventos fora de ordem. Se seu handler atualizar estado em toda entrega, você eventualmente criará registros duplicados, enviará e-mails duplicados ou cobrará duas vezes.

Comece escolhendo a melhor chave de dedup. Se o provedor te fornece um ID único de evento, use-o. Trate como chave de idempotência para o endpoint de webhook. Só recorra a um hash do payload quando não houver event ID.

Segurança vem primeiro: verifique a assinatura antes de aceitar qualquer coisa. Se a assinatura falhar, rejeite a requisição e não escreva um registro de dedup. Caso contrário, um atacante poderia “reservar” um ID de evento e bloquear eventos reais depois.

Um fluxo seguro sob retentativas:

  • Verifique assinatura e forma básica (headers obrigatórios, event ID).
  • Insira o event ID em uma tabela de dedup com restrição única.
  • Se a inserção falhar por duplicata, retorne 200 imediatamente.
  • Armazene o payload bruto (e headers) quando for útil para auditoria e debug.
  • Enfileire o processamento e retorne 200 rapidamente.

Confirmar rapidamente importa porque muitos provedores têm timeouts curtos. Faça o menor trabalho confiável na requisição: verificar, dedup, persistir. Depois processe assincronamente (worker, fila, job em background). Se não puder fazer async, mantenha o processamento idempotente chaveando efeitos colaterais internos ao mesmo event ID.

Entrega fora de ordem é normal. Não assuma que “created” chega antes de “updated”. Prefira upserts por ID externo e acompanhe o timestamp ou versão do último evento processado.

Armazenar payloads brutos ajuda quando um cliente diz “não recebemos a atualização”. Você pode reexecutar o processamento a partir do corpo armazenado após consertar um bug, sem pedir ao provedor que reenvie.

Concorrência: permanecer correto sob requisições paralelas

Retentativas ficam complicadas quando duas requisições com a mesma chave chegam ao mesmo tempo. Se ambos os handlers executarem a etapa “fazer o trabalho” antes de qualquer um salvar o resultado, você ainda pode cobrar em duplicidade, importar em duplicidade ou enfileirar duas vezes.

O ponto de coordenação mais simples é a transação do banco. Faça o primeiro passo ser “reclamar a chave” e deixe o banco decidir quem vence. Opções comuns:

  • Inserção única em uma tabela de dedup (o banco impõe um vencedor)
  • SELECT ... FOR UPDATE após criar (ou encontrar) a linha de dedup
  • Locks de advisory em nível de transação baseados num hash da chave de idempotência
  • Restrições únicas no registro de negócio como último recurso

Para trabalhos de longa duração, evite manter um lock de linha enquanto chama sistemas externos ou executa imports que demoram minutos. Em vez disso, armazene uma pequena máquina de estados na linha de dedup para que outras requisições saiam rápido.

Um conjunto prático de estados:

  • in_progress com started_at
  • completed com resposta em cache
  • failed com um código de erro (opcional, dependendo da política de retentativa)
  • expires_at (para limpeza)

Exemplo: duas instâncias do app recebem a mesma requisição de pagamento. A instância A insere a chave e marca in_progress, então chama o provedor. A instância B esbarra no caminho de conflito, lê a linha de dedup, vê in_progress e retorna uma resposta rápida de “ainda processando” (ou espera brevemente e rechecagens). Quando A termina, ela atualiza a linha para completed e armazena o corpo da resposta para que retentativas posteriores obtenham exatamente a mesma saída.

Erros comuns que quebram idempotência

Make payments charge once
Construa um fluxo de pagamento que evite cobranças em duplicidade com chaves de idempotência e IDs do provedor salvos.
Configurar pagamentos

A maioria dos bugs de idempotência não envolve bloqueios sofisticados. São escolhas “quase corretas” que falham sob retentativas, timeouts ou dois usuários fazendo ações semelhantes.

Uma armadilha comum é tratar a chave de idempotência como globalmente única. Se você não a escopar (por usuário, conta ou endpoint), dois clientes diferentes podem colidir e um receber o resultado do outro.

Outro problema é aceitar a mesma chave com um corpo diferente. Se a primeira chamada foi por $10 e o replay é por $100, você não deve retornar silenciosamente o primeiro resultado. Armazene um request hash (ou campos chave), compare no replay e retorne um erro de conflito claro.

Clientes também se confundem quando replays retornam um formato de resposta ou código de status diferente. Se a primeira chamada retornou 201 com um JSON, o replay deve retornar o mesmo corpo e um código consistente. Mudar comportamento de replay força clientes a adivinhar.

Erros que frequentemente causam duplicatas:

  • Confiar apenas em um mapa em memória ou cache, perdendo estado de dedup no restart.
  • Usar uma chave sem escopo (colisões cross-user ou cross-endpoint).
  • Não validar divergências de payload para a mesma chave.
  • Fazer o efeito colateral primeiro (cobrar, inserir, publicar) e escrever o registro de dedup depois.
  • Retornar um novo ID gerado a cada retentativa em vez de reproduzir o resultado original.

Um cache pode acelerar leituras, mas a fonte da verdade deve ser durável (normalmente PostgreSQL). Caso contrário, retentativas após um deploy podem criar duplicatas.

Planeje também a limpeza. Se você armazenar toda chave para sempre, as tabelas crescem e índices ficam lentos. Defina uma janela de retenção baseada no comportamento real de retentativas, delete linhas antigas e mantenha o índice único pequeno.

Checklist rápido e próximos passos

Trate idempotência como parte do contrato da sua API. Todo endpoint que pode ser re-tentado por um cliente, uma fila ou um gateway precisa de uma regra clara sobre o que “mesma requisição” significa e como é o “mesmo resultado”.

Checklist antes de lançar:

  • Para cada endpoint re-tentável, o escopo da idempotência está definido (por usuário, conta, pedido, evento externo) e documentado?
  • A deduplicação é aplicada pelo banco (restrição única na chave de idempotência e escopo), e não apenas “verificada no código”?
  • No replay, você retorna o mesmo código de status e corpo de resposta (ou um subconjunto estável documentado), não um objeto fresco com novo timestamp?
  • Para pagamentos, você trata resultados desconhecidos com segurança (timeout após submit, gateway diz “processing”) sem cobrar duas vezes?
  • Logs e métricas deixam claro quando uma requisição foi vista pela primeira vez vs quando foi um replay?

Se algum item for “talvez”, corrija agora. A maioria das falhas aparece sob estresse: retentativas paralelas, redes lentas e falhas parciais.

Se você está construindo ferramentas internas ou apps para clientes no AppMaster (appmaster.io), ajuda projetar chaves de idempotência e a tabela de dedup PostgreSQL cedo. Assim, mesmo que a plataforma regenere código backend Go quando requisitos mudarem, o comportamento de retry permanece consistente.

FAQ

Why do retries create duplicate charges or duplicate records even when my API is correct?

Retentativas são normais porque redes e clientes falham de maneiras comuns. Uma requisição pode ter sido executada com sucesso no servidor, mas a resposta não chega ao cliente; o cliente reenvia e você acaba fazendo o mesmo trabalho duas vezes, a menos que o servidor reconheça e repita o resultado original.

What should I use as an idempotency key, and who should generate it?

Envie a mesma chave em cada tentativa da mesma ação. Gere-a no cliente como uma string aleatória e imprevisível (por exemplo, um UUID) e não a reutilize para ações diferentes.

How should I scope idempotency keys so they don’t collide across users or tenants?

Escopo a chave para corresponder à sua regra de negócio, normalmente por endpoint mais uma identidade do chamador, como usuário, conta, tenant ou token de API. Isso evita que dois clientes diferentes colidam e recebam o resultado um do outro.

What should my API return when it receives a duplicate request with the same key?

Retorne o mesmo resultado da primeira tentativa bem-sucedida. Na prática, reproduza o mesmo código de status HTTP e corpo de resposta, ou ao menos o mesmo ID de recurso e estado, para que clientes possam re-tentar sem causar um segundo efeito colateral.

What if the client accidentally reuses the same idempotency key with a different request body?

Rejeite com um erro claro no estilo conflito em vez de adivinhar. Armazene e compare um hash dos campos importantes da requisição; se a chave for a mesma mas o payload for diferente, falhe rapidamente para evitar misturar duas operações distintas sob a mesma chave.

How long should I retain idempotency keys in my database?

Mantenha as chaves tempo suficiente para cobrir retentativas realistas, depois as remova. Um padrão comum é 24–72 horas para pagamentos, uma semana para imports, e para webhooks alinhe com a política de reenvio do remetente para que retentativas tardias ainda deduplicem corretamente.

What’s the simplest PostgreSQL schema pattern for idempotency?

Uma tabela dedicada de deduplicação funciona bem porque o banco impõe uma restrição única e sobrevive a reinícios. Armazene o escopo do owner, a chave, um hash da requisição, um status e a resposta para reproduzir; torne (owner, key) único para que apenas uma requisição “vença”.

How do I handle two identical requests arriving at the same time?

Reclame a chave dentro de uma transação do banco primeiro e só faça o efeito colateral se você a tiver conseguido. Se outra requisição chegar em paralelo, ela deverá esbarrar na restrição única, ver in_progress ou completed e retornar uma resposta de espera/replay em vez de executar a lógica duas vezes.

How do I prevent double-charging when the payment gateway times out?

Trate timeouts como “desconhecido”, não como “falha”. Grave um estado pendente e, se tiver um ID do provedor, use-o como fonte da verdade para que retentativas retornem o mesmo resultado de pagamento em vez de criar uma nova cobrança.

How can I make imports retry-safe without forcing users to start over or creating duplicates?

Faça deduplicação em dois níveis: job-level e item-level. Faça com que retentativas retornem o mesmo ID de job de importação e imponha uma chave natural para linhas (como um external ID ou (account_id, email)) com restrições únicas ou upserts para que o reprocessamento não crie duplicatas.

Fácil de começar
Criar algo espantoso

Experimente o AppMaster com plano gratuito.
Quando estiver pronto, você poderá escolher a assinatura adequada.

Comece