Idempotência¶
A Gubee entrega eventos de webhook com semântica at-least-once (ao menos uma vez), não exactly-once. Isso significa que o mesmo evento pode chegar ao seu endpoint múltiplas vezes, e o seu integrador precisa ser capaz de deduplicar com segurança.
Esse padrão é o mesmo adotado por provedores como Stripe, AWS SNS/SQS e GitHub. A camada de rede entre a Gubee e o seu endpoint é inerentemente não confiável: pacotes se perdem, conexões caem no meio da resposta, load balancers devolvem 502. Garantir exactly-once exigiria consenso distribuído caro; at-least-once + dedup no receptor é a solução pragmaticamente correta.
Cenários de duplicação¶
Os três casos abaixo são reais e acontecem mesmo em integrações sadias:
1. Entrega com sucesso, mas resposta lenta¶
O seu endpoint processou o webhook com sucesso, mas levou mais que o timeout configurado na Gubee (5s sugeridos). A Gubee considera falha e agenda retentativa. Resultado: o mesmo OrderInvoiced chega duas vezes.
T+0.0s Gubee envia OrderInvoiced (attempts: 1)
T+0.5s Você persiste no ERP com sucesso
T+6.0s Seu handler responde 200 (tarde demais)
T+6.0s Gubee marca como falha -> agenda retry
T+30m Gubee envia OrderInvoiced de novo (attempts: 2)
2. Fila retornando o mesmo pedido antes do ack¶
Se você usa a fila de pedidos (GET /integration/orders/queue/{status}), os pedidos reaparecem na listagem até você chamar DELETE /integration/orders/queue/{status}/{orderId}. Se o seu job de consumo ler a fila duas vezes antes do DELETE (ex.: duas réplicas do pod rodando em paralelo, ou um job que estourou timeout e rodou de novo), o mesmo orderId vai aparecer duas vezes.
Mesmo depois do DELETE, pode haver uma janela de inconsistência eventual entre o serviço de fila e o ack — o pedido pode reaparecer brevemente. Trate com tolerância.
3. Falha de rede no meio do caminho¶
A Gubee envia o POST, o seu endpoint processa e responde 200, mas o pacote de resposta se perde no meio do caminho. Para a Gubee, a tentativa falhou; para você, foi sucesso. A próxima retentativa entrega duplicada.
Esse caso é irredutível — não há como a Gubee saber se você processou ou não. Por isso a responsabilidade de dedup é sempre do receptor.
Solução: tabela de dedup¶
A regra é simples: antes de executar qualquer efeito colateral (gravar no ERP, emitir nota, atualizar estoque), verifique uma tabela local de eventos já processados. Se a chave já existe, acuse 2xx e abandone.
Escolha da chave¶
A chave de dedup precisa ser:
- Determinística — mesma para o mesmo evento em entregas repetidas.
- Estável — não depende de campos que mudam entre tentativas (ex.:
attempts).
A combinação recomendada é:
itemId: identificador do recurso (orderId, skuId, ID interno).type: tipo do evento (OrderInvoiced, etc.).received: timestamp de geração na Gubee. É o marco mais estável do evento — ele não muda entre retentativas (ao contrário desent, que muda a cada nova tentativa).
Não use sent nem attempts na chave — eles mudam a cada tentativa e vão resultar em chaves diferentes para o mesmo evento lógico.
Se você quiser ser ainda mais conservador, pode usar como chave apenas
(type, resource)ignorando o timestamp: isso deduplica mesmo entre eventos legítimos consecutivos do mesmo tipo (ex.: duas atualizações de estoque seguidas em 50ms costumam ser o mesmo evento em duplicata, mas às vezes são fatos distintos). Para preço/estoque, geralmente interessa apenas o estado final — então dedupar por(type, itemId)com TTL curto (ex.: 2s) é uma otimização válida. Para pedidos, sempre use(type, itemId, received).
Exemplo de tabela (PostgreSQL)¶
CREATE TABLE webhook_dedup (
dedup_key TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
item_id TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload JSONB NOT NULL
);
-- Índice para limpeza periódica (apagar entradas velhas)
CREATE INDEX idx_webhook_dedup_processed_at
ON webhook_dedup (processed_at);
-- TTL implícito: depois de 30 dias a entrada não serve mais para nada.
-- Rode um job diário:
-- DELETE FROM webhook_dedup WHERE processed_at < now() - interval '30 days';
Padrão de inserção idempotente¶
A inserção com ON CONFLICT DO NOTHING (Postgres) é o caminho mais seguro para detectar duplicata sem race condition:
INSERT INTO webhook_dedup (dedup_key, event_type, item_id, received_at, payload)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (dedup_key) DO NOTHING
RETURNING dedup_key;
- Se retornar uma linha: evento novo, prossiga com o processamento de negócio.
- Se retornar vazio: duplicata, acuse
2xxe abandone.
Esse padrão é atomicamente seguro mesmo sob concorrência (vários pods do seu handler processando em paralelo).
Handler completo (pseudo-Kotlin)¶
@PostMapping("/gubee/webhook")
fun handle(@RequestBody raw: String): ResponseEntity<Void> {
val event = objectMapper.readValue(raw, WebhookEvent::class.java)
// 1. Filtro de contexto (ver events.md) -- ignore o que não é seu canal
if (!eventBelongsToMyContext(event)) {
return ResponseEntity.ok().build()
}
// 2. Dedup atômica
val dedupKey = sha256("${event.itemId}|${event.type}|${event.received}")
val inserted = dedupRepository.tryInsert(
key = dedupKey,
type = event.type,
itemId = event.itemId,
received = event.received,
payload = raw
)
if (!inserted) {
// Duplicata -> já processamos antes.
log.info("duplicate event ${event.type} for ${event.itemId}, ignoring")
return ResponseEntity.ok().build()
}
// 3. Persiste na fila interna para worker assíncrono e responde rápido.
inboxRepository.save(WebhookInbox(payload = raw, status = Status.PENDING))
return ResponseEntity.ok().build()
}
O processamento pesado (chamar ERP, emitir nota) roda num worker separado lendo a webhook_inbox. Esse desacoplamento garante que o handler responda dentro do timeout da Gubee mesmo se o ERP estiver lento.
Fila de pedidos vs. webhook: mesma chave¶
Se você usa ambos (webhook + fila /integration/orders/queue/{status}), a mesma lógica de dedup se aplica — porque a fila também é at-least-once. Use a mesma tabela webhook_dedup e a mesma estratégia de chave:
fun consumeQueue(status: String) {
val orders = gubeeClient.listQueue(status, size = 50)
for (order in orders) {
// Mesmo shape do webhook: itemId=orderId, type=Order<Status>
val dedupKey = sha256("${order.orderId}|Order${status.titlecase()}|${order.updatedAt}")
if (!dedupRepository.tryInsert(...)) continue
orderService.sync(order)
gubeeClient.ackQueue(status, order.orderId) // DELETE
}
}
Depois de um DELETE /integration/orders/queue/{status}/{orderId}, o mesmo pedido pode reaparecer brevemente na próxima paginação por consistência eventual — a dedup cobre essa janela.
Filosofia Stripe¶
A Gubee segue a mesma filosofia da Stripe para idempotência:
- O provedor garante at-least-once.
- O receptor mantém uma tabela de chaves de idempotência.
- A chave é determinística e derivada do evento (não gerada randomicamente).
Referência externa para o padrão: https://stripe.com/docs/api/idempotent_requests. A diferença é que a Gubee não envia uma chave Idempotency-Key no header — você deriva a chave do próprio corpo (itemId + type + received).
Resumo operacional¶
| Prática | Por quê |
|---|---|
| Salvar payload bruto em tabela local | Replay e auditoria sem depender da Gubee |
Dedup atômica (INSERT ... ON CONFLICT) |
Segura sob concorrência |
Chave = hash(itemId + type + received) |
Estável entre retentativas |
| Worker assíncrono fora do handler HTTP | Evita timeout da Gubee |
| Job diário de limpeza da dedup | Evita crescimento infinito |
| Mesma dedup para webhook e fila | Unifica a semântica |
Próximos passos¶
- Eventos — catálogo dos
typepara montar a chave. - Política de retentativa — de onde vêm as duplicações de retentativa.
- Schema do payload — significado exato de cada campo.