Idempotência em Webhooks de IA: Por Que Importa em Produção
Webhooks sem idempotência em sistemas de IA são bombas-relógio: silenciosas até explodir em produção. Veja como blindar suas integrações com exemplos reais de código.
Seu sistema de IA processou o mesmo evento três vezes. Cobrou o cliente três vezes. Disparou três e-mails de confirmação. E você só ficou sabendo quando o suporte abriu um ticket na segunda-feira de manhã.
Esse cenário não é hipotético. Aconteceu comigo durante o desenvolvimento do CaixaHub, quando os webhooks do Open Finance começaram a chegar em duplicata durante instabilidades de rede. O provider reenvia. O load balancer reenvia. Seu próprio retry reenvia. Sem idempotência em webhooks de IA, cada reenvio é uma bomba silenciosa esperando explodir.
O problema fica pior quando IA entra na equação. Uma chamada à OpenAI ou Anthropic não é gratuita — nem em dinheiro, nem em latência. Processar o mesmo evento duas vezes significa pagar duas vezes, armazenar resultado duplicado, e possivelmente tomar decisões de negócio conflitantes com base em dados inconsistentes.
Neste artigo você vai aprender: o que exatamente torna um webhook handler idempotente, por que isso é crítico em fluxos que envolvem modelos de linguagem, e como implementar deduplicação robusta com retry exponencial em menos de 30 minutos. Com código real, sem abstração vaga.
O que "idempotente" significa de verdade (sem a teoria de faculdade)
Idempotência é uma propriedade matemática. Na prática: executar a mesma operação N vezes produz o mesmo resultado que executar uma vez.
f(f(x)) = f(x)
Em webhooks, isso significa: receber o mesmo evento duas vezes não muda o estado do sistema além do que a primeira execução já mudou.
Parece óbvio. Mas a maioria dos handlers que vejo em código de produção não é idempotente. Por quê? Porque o caminho natural de escrever código é linear: chega evento → processa → salva resultado. Ninguém pensa no reenvio até ele acontecer.
Alerta: Providers de webhook sérios (Stripe, GitHub, Open Finance) explicitamente documentam que vão reenviar eventos em caso de falha de entrega. Isso não é bug — é spec. Seu sistema precisa estar preparado.
O ciclo de vida de um evento reenviado
Quando um provider não recebe confirmação (HTTP 200) dentro do timeout, ele reenvia. Os motivos mais comuns:
- Seu servidor demorou pra responder (processamento pesado com IA)
- Deploy aconteceu durante o processamento
- Falha de rede entre provider e seu servidor
- Seu handler retornou 500 por erro interno
O problema específico com IA: chamadas a modelos grandes têm latência de 2 a 15 segundos facilmente. Muitos providers têm timeout de 5-10 segundos. Você responde devagar, o provider reenvia, você processa duas vezes. Essa é a armadilha mais comum em arquiteturas que adicionam IA a um webhook existente sem rearquitetar o handler.
Por que webhooks de IA têm o problema amplificado
Num webhook tradicional — digamos, atualização de status de pedido — processar duas vezes é ruim mas recuperável. Você sobrescreve com o mesmo valor, no máximo.
Em fluxos com IA, os efeitos colaterais são mais pesados:
Custo financeiro direto. Cada chamada à API da Anthropic ou OpenAI tem custo por token. Processar o mesmo documento 3x = pagar 3x. Em alto volume, isso corrói margem de forma invisível.
Efeitos colaterais assimétricos. Se seu webhook dispara um agente que envia uma proposta comercial, você não quer enviar a mesma proposta duas vezes pro mesmo lead. O dano à reputação é real.
Estado inconsistente em banco. Dois processos paralelos gerando e salvando resultados de IA para o mesmo evento podem criar registros conflitantes — especialmente se você usa INSERT em vez de UPSERT.
Consumo de rate limit. APIs de IA têm limites de requisição por minuto. Reprocessamentos desnecessários consomem esse limite, afetando requests legítimas.
A anatomia de um handler idempotente
A solução central é simples: antes de processar qualquer coisa, verificar se esse evento já foi processado. Se sim, retornar sucesso imediatamente sem executar nada.
Isso requer três componentes:
- Identificador único do evento (geralmente fornecido pelo provider)
- Store de deduplicação (Redis, banco de dados, qualquer coisa persistente)
- Lógica de check antes do processamento
import redisimport jsonfrom functools import wrapsr = redis.Redis(host='localhost', port=6379, db=0)IDEMPOTENCY_TTL = 86400 # 24 horas em segundosdef idempotent_handler(func):@wraps(func)def wrapper(event: dict, *args, **kwargs):event_id = event.get("id") or event.get("event_id")if not event_id:# Sem ID, não dá pra garantir idempotência — log e processaprint(f"[WARN] Evento sem ID recebido: {event}")return func(event, *args, **kwargs)idempotency_key = f"webhook:processed:{event_id}"# Tentativa atômica: só seta se não existir (NX)was_set = r.set(idempotency_key, "1", ex=IDEMPOTENCY_TTL, nx=True)if not was_set:# Já processado — retorna sem fazer nadaprint(f"[INFO] Evento duplicado ignorado: {event_id}")return {"status": "already_processed", "event_id": event_id}try:return func(event, *args, **kwargs)except Exception as e:# Falhou — remove a chave pra permitir retry legítimor.delete(idempotency_key)raise ereturn wrapper
O detalhe crítico está na linha nx=True. O comando SET ... NX do Redis é atômico — só seta se a chave não existe. Isso evita race condition quando dois workers recebem o mesmo evento simultaneamente.
Outro detalhe: se o processamento falhou (exceção), a chave é deletada. Isso permite que o provider reenvie e o sistema tente de novo. Idempotência não significa "nunca tenta de novo" — significa "não processa o mesmo evento bem-sucedido duas vezes".
Implementando retry exponencial no lado do consumidor
Idempotência resolve o problema do reenvio externo. Mas você também precisa de retry interno — quando sua chamada à API de IA falha.
A regra de ouro: retry com backoff exponencial e jitter.
import timeimport randomfrom anthropic import Anthropic, APIError, RateLimitErrorclient = Anthropic()def call_ai_with_retry(prompt: str,max_retries: int = 4,base_delay: float = 1.0,max_delay: float = 60.0) -> str:for attempt in range(max_retries):try:message = client.messages.create(model="claude-haiku-4-5",max_tokens=1024,messages=[{"role": "user", "content": prompt}])return message.content[0].textexcept RateLimitError:if attempt == max_retries - 1:raise# Rate limit: espera maisdelay = min(base_delay * (2 ** attempt) * 2, max_delay)jitter = random.uniform(0, delay * 0.1)time.sleep(delay + jitter)except APIError as e:if attempt == max_retries - 1:raiseif e.status_code and e.status_code < 500:# Erro 4xx: não vai melhorar com retryraisedelay = min(base_delay * (2 ** attempt), max_delay)jitter = random.uniform(0, delay * 0.1)time.sleep(delay + jitter)raise RuntimeError("Max retries exceeded")
O jitter — ruído aleatório adicionado ao delay — é fundamental em sistemas com múltiplos workers. Sem ele, todos os workers que falharam simultaneamente vão tentar de novo ao mesmo tempo, criando um thundering herd que piora o problema de rate limit.
Insight contrarian: A maioria dos tutoriais de webhook mostra o handler respondendo 200 após processar tudo. Em fluxos com IA, isso é erro de arquitetura. O correto é responder 200 imediatamente, enfileirar o processamento, e usar um worker assíncrono. O provider não precisa saber quanto tempo sua IA demorou.
Respondendo rápido, processando depois: o padrão correto
A arquitetura robusta para webhooks com IA tem duas fases separadas: recepção e processamento.
# Django view — responde em <50ms, semprefrom django.http import JsonResponsefrom django.views.decorators.csrf import csrf_exemptfrom .tasks import process_ai_webhook # Celery taskimport jsonimport hmacimport hashlib@csrf_exemptdef webhook_receiver(request):if request.method != "POST":return JsonResponse({"error": "Method not allowed"}, status=405)# 1. Verificar assinatura (obrigatório)signature = request.headers.get("X-Signature-256", "")if not verify_signature(request.body, signature):return JsonResponse({"error": "Invalid signature"}, status=401)# 2. Parse mínimo — só o necessário pra identificartry:payload = json.loads(request.body)event_id = payload.get("id")except json.JSONDecodeError:return JsonResponse({"error": "Invalid JSON"}, status=400)# 3. Enfileira pra processamento assíncronoprocess_ai_webhook.delay(event_id, payload)# 4. Responde imediatamente — não espera IA terminarreturn JsonResponse({"status": "queued", "event_id": event_id}, status=200)def verify_signature(payload: bytes, signature: str) -> bool:secret = b"seu_webhook_secret_aqui"expected = "sha256=" + hmac.new(secret, payload, hashlib.sha256).hexdigest()return hmac.compare_digest(expected, signature)
# tasks.py — o worker Celery que faz o trabalho pesadofrom celery import shared_taskfrom .idempotency import idempotent_handlerfrom .ai import call_ai_with_retry@shared_task(bind=True, max_retries=3)def process_ai_webhook(self, event_id: str, payload: dict):idempotency_key = f"webhook:processed:{event_id}"# Check de idempotência dentro do worker tambémif redis_client.exists(idempotency_key):return {"status": "already_processed"}try:# Processamento real com IAresult = call_ai_with_retry(prompt=build_prompt_from_payload(payload))# Salva resultadosave_result_to_db(event_id, result)# Marca como processadoredis_client.set(idempotency_key, "1", ex=86400, nx=True)return {"status": "processed", "event_id": event_id}except Exception as exc:# Retry com backoffraise self.retry(exc=exc, countdown=2 ** self.request.retries)
Esse padrão — receiver leve + worker assíncrono — resolve três problemas de uma vez: timeout do provider, retry seguro com idempotência, e separação de responsabilidades.
Deduplicação no banco de dados: a segunda linha de defesa
Redis pode falhar. Cache pode ser limpo. Você precisa de uma segunda camada de proteção no banco de dados.
-- PostgreSQL: constraint unique no event_idCREATE TABLE webhook_events (id BIGSERIAL PRIMARY KEY,event_id VARCHAR(255) NOT NULL UNIQUE,payload JSONB NOT NULL,processed_at TIMESTAMPTZ DEFAULT NOW(),result JSONB);-- Índice pra performance em consultasCREATE INDEX idx_webhook_events_event_id ON webhook_events(event_id);
from django.db import IntegrityErrorfrom .models import WebhookEventdef save_result_to_db(event_id: str, payload: dict, result: dict):try:WebhookEvent.objects.create(event_id=event_id,payload=payload,result=result)except IntegrityError:# Duplicate key — evento já foi salvo por outro worker# Isso é esperado e corretoprint(f"[INFO] Evento já existe no banco: {event_id}")return
O UNIQUE no banco é o guard final. Se Redis falhou e dois workers processaram o mesmo evento em paralelo, o segundo vai receber IntegrityError no INSERT. Você trata isso como sucesso silencioso — o evento foi processado, só não por esse worker.
Quick Win: blindar um webhook existente em 30 minutos
Se você tem um webhook handler hoje que não tem idempotência, aqui está o passo a passo mínimo para adicionar:
Passo 1 — Instalar Redis client (2 min)
pip install redis # Python# ounpm install ioredis # Node.js
Passo 2 — Adicionar coluna no banco (5 min)
ALTER TABLE seu_modelo ADD COLUMN IF NOT EXISTSwebhook_event_id VARCHAR(255) UNIQUE;
Passo 3 — Criar função de check (10 min)
import redisr = redis.Redis.from_url("redis://localhost:6379/0")def is_duplicate_event(event_id: str) -> bool:"""Retorna True se o evento já foi processado."""key = f"wh:{event_id}"# SET retorna None se a chave já existiaresult = r.set(key, "1", ex=3600, nx=True)return result is None
Passo 4 — Adicionar no início do handler (5 min)
def meu_webhook_handler(request):payload = json.loads(request.body)event_id = payload.get("id")if event_id and is_duplicate_event(event_id):return JsonResponse({"status": "ok"}, status=200) # ignora silenciosamente# ... resto do código existente
Passo 5 — Testar com evento duplicado (8 min)
# Simule reenvio duplicadocurl -X POST http://localhost:8000/webhook \-H "Content-Type: application/json" \-d '{"id": "evt_test_123", "type": "payment.completed"}'# Mesma requisição — deve retornar ok sem processarcurl -X POST http://localhost:8000/webhook \-H "Content-Type: application/json" \-d '{"id": "evt_test_123", "type": "payment.completed"}'
Se o segundo request não disparou lógica de negócio, você está protegido.
O que acontece quando você ignora isso em escala
Durante o CaixaHub, processei webhooks de eventos bancários do Open Finance. Em dias de instabilidade — que são comuns no sistema financeiro brasileiro — a taxa de eventos duplicados chegava a 15%. Sem idempotência, 15% das transações seriam processadas em duplicata.
Em um sistema de pagamentos, isso não é bug de UX. É fraude acidental.
O padrão que funcionou: receiver síncrono respondendo em <100ms, Celery worker com deduplicação Redis + constraint único no banco, retry com backoff de 1s → 2s → 4s → 8s, e um dashboard simples mostrando a taxa de eventos duplicados por hora.
O dashboard foi o mais valioso. Picos na taxa de duplicatas indicavam problema no provider ou na rede — não no meu código. Virou sinal de alerta antecipado pra instabilidades externas.
Idempotência não é feature — é requisito
Sistemas que processam eventos externos sem idempotência estão funcionando por sorte. O provider não reenviar não significa que nunca vai reenviar.
Quando você adiciona IA ao pipeline, a aposta fica mais alta. Custo por token, efeitos colaterais assimétricos, latência que provoca timeout — tudo conspira pra transformar a ausência de idempotência em problema de produção em semanas, não meses.
A boa notícia: o padrão é estabelecido, o código é pequeno, e você pode implementar hoje. O Redis SET com NX é atômico. O UNIQUE constraint no banco é gratuito. O receiver assíncrono desacopla velocidade de resposta de complexidade de processamento.
Você não precisa de uma biblioteca especial. Precisa de disciplina arquitetural.
Se você está construindo automações com IA e quer mapear onde sua arquitetura atual tem pontos cegos de produção, o diagnóstico do site pode ajudar a identificar isso rapidamente: levilael.com.br/diagnosis