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.

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çãoresponse: o payload final de resposta (frequentemente JSON) ou um ponteiro para um resultado armazenadocreated_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_atpara 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
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:
-
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).
-
Inicie uma transação no banco e tente criar um registro de dedup. Faça-o único em
(owner, key). Armazenerequest_hash, status (started, completed) e placeholders para a resposta. -
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.
-
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).
-
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á tiverprovider_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_ide 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
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
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 UPDATEapó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_progresscomstarted_atcompletedcom resposta em cachefailedcom 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
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
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.
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.
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.
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.
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.
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.
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”.
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.
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.
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.


