Pular para conteúdo

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:

  1. Determinística — mesma para o mesmo evento em entregas repetidas.
  2. Estável — não depende de campos que mudam entre tentativas (ex.: attempts).

A combinação recomendada é:

hash(itemId + type + received)
  • 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 de sent, 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 2xx e 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