MÓDULO 09 · CONCEITO 11 DE 14

Idempotência e at-least-once delivery

Por que sistemas distribuídos entregam mensagens mais de uma vez — e como projetar consumidores que não se importam

Tempo de leitura ~22 min Pré-requisito 08 · Message Queues · 10 · Padrões de Mensageria Assíncrona Próximo 12 · Service Discovery

Em qualquer sistema distribuído, a rede é não confiável. Uma mensagem publicada pode ser entregue zero vezes (o broker caiu), uma vez (caso feliz), ou mais de uma vez (o ACK se perdeu e o broker reenviou). A escolha entre at-most-once, at-least-once e exactly-once não é uma configuração de broker — é um contrato arquitetural com consequências em cada camada do sistema. A grande maioria dos sistemas de produção opera em at-least-once porque exactly-once é proibitivo fora de ambientes controlados, e at-most-once é inaceitável quando perder mensagens tem custo de negócio. A consequência direta de at-least-once é que consumidores precisam ser idempotentes.

As três garantias de entrega

At-most-once: a mensagem é entregue zero ou uma vez. O produtor envia e esquece; se houver falha de rede, a mensagem é perdida. Simples de implementar — sem retry, sem ACK — e de baixíssima latência. Adequado apenas para dados verdadeiramente descartáveis: métricas de baixa prioridade, heartbeats, telemetria onde perder 0,1% das amostras não importa.

At-least-once: a mensagem é entregue uma ou mais vezes. O broker reenvia até receber ACK do consumidor. Se o consumidor processar e morrer antes de dar o ACK, o broker reenvia — o processamento aconteceu duas vezes. É a garantia padrão de RabbitMQ, SQS Standard, Kafka com autocommit desabilitado. O processamento deve ser idempotente.

Exactly-once: a mensagem é processada exatamente uma vez, mesmo com falhas. É a garantia mais cara — requer coordenação entre produtor, broker e consumidor. Kafka suporta exactly-once dentro do ecossistema Kafka (Kafka Streams, produtor idempotente + consumer com transações). Para qualquer saída que não seja Kafka (banco de dados, API externa, email), você volta ao problema do dual write — e está de volta ao at-least-once com idempotência.

atenção "Exactly-once" em marketing de brokers frequentemente significa exactly-once de entrega, não de processamento. Se o consumidor fizer uma chamada a uma API externa durante o processamento e a mensagem for reentregue, a API externa foi chamada duas vezes — não importa quantas vezes o broker "entregou" a mensagem. Exactly-once de processamento requer que todo efeito colateral seja idempotente ou faça parte da transação do broker.

Por que at-least-once é inevitável

O problema fundamental é o two generals problem: você não pode ter certeza absoluta que uma mensagem chegou sem uma confirmação, e a confirmação também pode se perder. Em termos práticos:

Cenário 1: mensagem perdida antes da entrega
  Broker → [rede falha] → Consumidor
  Resultado: consumidor nunca recebeu
  Solução: retry → at-least-once

Cenário 2: processamento ok, ACK perdido
  Consumidor processa mensagem
  Consumidor → ACK → [rede falha] → Broker
  Broker: não recebi ACK, reenvia
  Consumidor processa de novo
  Resultado: processamento duplicado

Cenário 3: consumidor morre após processar
  Consumidor processa mensagem
  Consumidor morre (crash, OOM, kill)
  Broker: visibility timeout expirou, reenvia
  Outro consumidor processa de novo
  Resultado: processamento duplicado

Os cenários 2 e 3 são inevitáveis em at-least-once. Não há configuração de broker que os elimine sem abrir mão do at-least-once. A única resposta correta é: aceite que duplicatas vão acontecer e projete os consumidores para que duplicatas sejam inofensivas.

Idempotência — definição formal e prática

Uma operação é idempotente se aplicá-la múltiplas vezes produz o mesmo resultado que aplicá-la uma única vez. Formalmente: f(f(x)) = f(x). Na prática de sistemas distribuídos: processar a mesma mensagem duas vezes deve ter o mesmo efeito que processar uma vez.

Operações naturalmente idempotentes: SET balance = 100 (não importa quantas vezes execute, o saldo fica 100), DELETE FROM sessions WHERE id = 'abc' (segunda execução não encontra nada, sem efeito), INSERT OR IGNORE / UPSERT por chave natural.

Operações que não são idempotentes por natureza: balance = balance + 50 (incremento — segunda execução soma de novo), INSERT INTO orders sem constraint de unicidade (cria duplicata), envio de email (segundo envio dispara email de novo), cobrança de cartão (segundo processamento cobra o cliente duas vezes).

A solução para operações não-idempotentes é introduzir um idempotency key — um identificador único da operação que permite detectar duplicatas e ignorá-las.

Estratégias de idempotência

Idempotency key + registro de processados

Cada mensagem carrega um ID único. O consumidor registra os IDs processados em um store (banco, Redis). Ao receber uma mensagem, verifica se o ID já foi processado antes de executar.

-- Tabela de mensagens processadas (inbox pattern)
CREATE TABLE processed_messages (
    message_id   TEXT PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    result       JSONB        -- opcional: cachear o resultado
);

-- Índice por tempo para limpeza periódica
CREATE INDEX processed_messages_time ON processed_messages (processed_at);

A chave está em verificar E registrar na mesma transação de banco de dados que executa o efeito. Se você verificar fora da transação, duas instâncias concorrentes podem passar na verificação simultaneamente e ambas processarem.

-- Padrão correto: tudo na mesma transação
BEGIN;
  -- Tenta inserir o ID. Falha se já existe (PK violation) → duplicata
  INSERT INTO processed_messages (message_id) VALUES ($1);

  -- Efeito de negócio
  UPDATE accounts SET balance = balance + $2 WHERE id = $3;
COMMIT;

-- Se a INSERT falhar por PK violation, nenhum UPDATE acontece

Conditional update com versão

Em vez de uma tabela separada, use um campo de versão ou timestamp na própria entidade. O update só acontece se a versão do evento for maior que a versão atual — duplicatas chegam com versão menor e são naturalmente ignoradas.

-- UPDATE condicional por versão
UPDATE accounts
SET balance = $new_balance, version = $event_version
WHERE id = $account_id
  AND version < $event_version;

-- Rows affected = 0 → duplicata (versão já foi processada)
-- Rows affected = 1 → processado com sucesso

Esta abordagem é eficiente (sem tabela extra) mas requer que os eventos carreguem um número de sequência monotônico por agregado — o que nem sempre é garantido pelo broker.

Upsert com chave natural

Para operações de criação, use INSERT ON CONFLICT DO NOTHING ou INSERT ON CONFLICT DO UPDATE. A chave natural do negócio (ex: order_id) serve como idempotency key implícita.

-- PostgreSQL: INSERT idempotente
INSERT INTO orders (id, customer_id, total, status)
VALUES ($1, $2, $3, 'placed')
ON CONFLICT (id) DO NOTHING;
-- Segunda execução: não faz nada, não falha, não duplica

Idempotência em APIs HTTP

Em APIs REST, o cliente envia um Idempotency-Key header. O servidor armazena o resultado da primeira requisição e retorna o mesmo resultado para requisições subsequentes com a mesma chave — sem reprocessar.

POST /payments
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Content-Type: application/json

{ "amount": 150.00, "currency": "BRL", "account_id": "acc-123" }

-- Primeira chamada: processa e salva resultado
-- Segunda chamada com mesma Idempotency-Key: retorna resultado salvo
-- HTTP 200 em ambos os casos (ou 201 na primeira, 200 na segunda)

Stripe, PayPal e a maioria das APIs de pagamento implementam idempotency keys. O TTL da chave (quanto tempo o servidor lembra) é tipicamente 24h-7 dias. Clientes devem gerar a chave antes de fazer a requisição, não depois — assim se a requisição timeout, a retry usa a mesma chave.

Deduplicação no nível do broker

Alguns brokers oferecem deduplicação nativa:

SQS FIFO: aceita MessageDeduplicationId (hash do conteúdo ou ID explícito). Mensagens com o mesmo ID dentro de uma janela de 5 minutos são descartadas pelo broker antes de chegar ao consumidor. Reduz a carga de deduplicação na aplicação, mas não elimina — o produtor pode enviar o mesmo ID em janelas diferentes.

Kafka produtor idempotente: com enable.idempotence=true, o produtor atribui sequence numbers a cada mensagem. O broker detecta e descarta retransmissões dentro de uma sessão. Garante exactly-once de publicação — não de processamento pelo consumidor.

RabbitMQ: não tem deduplicação nativa. Cada mensagem tem um message_id que o consumidor pode usar, mas a deduplicação é inteiramente responsabilidade da aplicação.

Timeout e retry com backoff exponencial

Idempotência é necessária, mas não suficiente. O retry precisa de backoff exponencial com jitter para evitar thundering herd — situação onde todas as instâncias de um consumidor tentam retry ao mesmo tempo após uma falha, sobrecarregando o sistema que já estava sob pressão.

// Fórmula de backoff exponencial com jitter
delay = min(base * 2^attempt, max_delay)
delay_with_jitter = delay * (0.5 + random(0, 0.5))

// Exemplo com base=1s, max=60s:
// Attempt 0: 0.5-1.0s
// Attempt 1: 1.0-2.0s
// Attempt 2: 2.0-4.0s
// Attempt 3: 4.0-8.0s
// Attempt 4: 8.0-16.0s
// Attempt 5: 16.0-32.0s
// Attempt 6+: 30-60s (capped)

O jitter distribui os retries no tempo, reduzindo o pico de carga. "Full jitter" (multiplicar por random 0-1) é mais eficaz que "equal jitter" (adicionar random a um base). AWS recomenda full jitter baseado em estudos empíricos com SQS.

Dead Letter Queue na perspectiva de idempotência

Após N retries sem sucesso, a mensagem vai para a DLQ. A DLQ não é um depósito de lixo — é uma fila de mensagens que precisam de intervenção manual ou automática. Toda DLQ deve ter:

Monitoramento: alerta quando há mensagens na DLQ (zero mensagens é o estado normal). Metadados de falha: motivo do erro, número de tentativas, última exceção. Mecanismo de reprocessamento: capacidade de mover mensagens da DLQ de volta para a fila original após corrigir o bug — reprocessamento deve ser manual ou com aprovação, nunca automático sem análise.

dica Mensagens na DLQ frequentemente revelam bugs de idempotência. Se uma mensagem foi para a DLQ após 3 tentativas e cada tentativa tinha um efeito parcial diferente, o consumidor não é idempotente — e você tem inconsistência de dados em produção, não só uma mensagem perdida.

Idempotência em chamadas síncronas

At-least-once e idempotência não são exclusivos de mensageria. Toda chamada HTTP com retry tem o mesmo problema. Um POST /charges com retry automático (por timeout ou 5xx) pode criar duas cobranças. As mesmas estratégias se aplicam: idempotency key, conditional update, upsert.

O padrão de retry em HTTP deve sempre checar se a operação é idempotente antes de retentar. GET é sempre seguro para retry. PUT e DELETE são idempotentes por definição REST. POST não é — requer idempotency key explícita ou lógica de deduplicação na aplicação.

Comparativo entre linguagens — consumidor idempotente

C# — MassTransit + EF Core
// C# — consumidor idempotente com MassTransit e EF Core
// MassTransit Inbox (InMemory ou persistido) + retries com backoff

public class OrderPlacedConsumer(AppDbContext db, ILogger<OrderPlacedConsumer> log)
    : IConsumer<OrderPlaced> {

    public async Task Consume(ConsumeContext<OrderPlaced> context) {
        var msg = context.Message;

        // Tentativa de inserção idempotente na tabela inbox
        // MassTransit faz isso automaticamente com .UseInbox()
        // Aqui, exemplo manual com EF Core:
        var alreadyProcessed = await db.ProcessedMessages
            .AnyAsync(m => m.MessageId == context.MessageId.ToString());

        if (alreadyProcessed) {
            log.LogInformation("Mensagem {Id} já processada, ignorando", context.MessageId);
            return; // não precisa rejeitar — ACK normalmente
        }

        // Efeito de negócio + registro do inbox na mesma transação
        await using var tx = await db.Database.BeginTransactionAsync();

        try {
            await db.ProcessedMessages.AddAsync(new ProcessedMessage {
                MessageId = context.MessageId.ToString(),
                ProcessedAt = DateTimeOffset.UtcNow,
            });

            await db.InventoryReservations.AddAsync(new InventoryReservation {
                OrderId = msg.OrderId,
                Items = msg.Items,
                ReservedAt = DateTimeOffset.UtcNow,
            });

            await db.SaveChangesAsync();
            await tx.CommitAsync();
        } catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation()) {
            // Race condition: outra instância processou ao mesmo tempo
            await tx.RollbackAsync();
            log.LogWarning("Race na deduplicação de {Id}", context.MessageId);
            // ACK mesmo assim — foi processado pela outra instância
        }
    }
}

// Configuração de retry com backoff (no Program.cs)
services.AddMassTransit(x => {
    x.AddConsumer<OrderPlacedConsumer>();
    x.UsingRabbitMq((ctx, cfg) => {
        cfg.UseMessageRetry(r => r
            .Exponential(5,              // máximo de 5 tentativas
                TimeSpan.FromSeconds(1), // delay inicial
                TimeSpan.FromSeconds(60), // delay máximo
                TimeSpan.FromSeconds(1)  // delta para variação (jitter)
            )
            .Ignore<ArgumentException>()  // não retentar erros de validação
        );
        cfg.UseInMemoryOutbox(ctx);
        cfg.ConfigureEndpoints(ctx);
    });
});

MassTransit integra retry, inbox e outbox com configuração declarativa. A extensão IsUniqueConstraintViolation() precisa ser implementada verificando o código de erro do Postgres (23505) ou SQL Server (2627).

Python — aio-pika + SQLAlchemy
# Python — consumidor idempotente com aio-pika e SQLAlchemy
# Deduplicação via tabela processed_messages com constraint UNIQUE

import asyncio
import logging
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
import aio_pika

log = logging.getLogger(__name__)

async def handle_order_placed(
    message: aio_pika.IncomingMessage,
    session_factory,
) -> None:
    message_id = message.message_id
    if not message_id:
        # Mensagens sem ID não podem ser deduplicadas — falha explícita
        raise ValueError("Mensagem sem message_id — impossível garantir idempotência")

    async with message.process(requeue=True):  # requeue em exceção não tratada
        async with session_factory() as session:
            async with session.begin():
                try:
                    # INSERT na tabela de deduplicação
                    # Falha com IntegrityError se message_id já existe (PK violation)
                    await session.execute(
                        text("INSERT INTO processed_messages (message_id, processed_at) VALUES (:id, NOW())"),
                        {"id": message_id}
                    )

                    # Efeito de negócio na mesma transação
                    payload = json.loads(message.body)
                    await reserve_inventory(session, payload["order_id"], payload["items"])

                    # Commit implícito no fim do context manager session.begin()

                except IntegrityError:
                    # PK violation → mensagem já foi processada
                    log.info("Mensagem %s já processada, ignorando", message_id)
                    await session.rollback()
                    # Sai sem requeue — mensagem será ACKada normalmente


# Retry com backoff exponencial (implementação manual)
async def consume_with_retry(
    channel: aio_pika.Channel,
    queue_name: str,
    session_factory,
    max_retries: int = 5,
) -> None:
    queue = await channel.declare_queue(queue_name, durable=True)

    async def on_message(msg: aio_pika.IncomingMessage) -> None:
        retry_count = int(msg.headers.get("x-retry-count", 0))

        try:
            await handle_order_placed(msg, session_factory)
        except Exception as e:
            if retry_count >= max_retries:
                log.error("Mensagem %s excedeu %d retries, enviando para DLQ", msg.message_id, max_retries)
                await send_to_dlq(channel, msg, str(e))
                async with msg.process():
                    pass  # ACK para remover da fila principal
                return

            # Backoff exponencial com jitter
            delay = min(2 ** retry_count, 60) * (0.5 + asyncio.get_event_loop().time() % 0.5)
            log.warning("Retry %d em %.1fs para %s", retry_count + 1, delay, msg.message_id)
            await asyncio.sleep(delay)

            # Republica com contador de retry incrementado
            await channel.default_exchange.publish(
                aio_pika.Message(
                    body=msg.body,
                    headers={**msg.headers, "x-retry-count": retry_count + 1},
                    message_id=msg.message_id,
                    delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
                ),
                routing_key=queue_name,
            )
            async with msg.process():
                pass  # ACK original após republicar

    await queue.consume(on_message)

Em Python, o retry via republish no mesmo topic com header x-retry-count é o padrão mais comum sem plugin externo. Bibliotecas como Celery ou Dramatiq abstraem isso, mas para mensageria de baixo nível com aio-pika a implementação manual é necessária.

Go — amqp091 + pgx
// Go — consumidor idempotente com amqp091 e pgx
// Deduplicação via INSERT ON CONFLICT + retry com backoff

package consumer

import (
    "context"
    "encoding/json"
    "fmt"
    "math"
    "math/rand"
    "time"

    amqp "github.com/rabbitmq/amqp091-go"
    "github.com/jackc/pgx/v5/pgxpool"
    "log/slog"
)

type OrderPlacedEvent struct {
    OrderID    string   `json:"order_id"`
    CustomerID string   `json:"customer_id"`
    Items      []string `json:"items"`
}

func ConsumeWithIdempotency(ctx context.Context, ch *amqp.Channel, pool *pgxpool.Pool) error {
    msgs, err := ch.Consume("orders.placed", "", false, false, false, false, nil)
    if err != nil {
        return fmt.Errorf("consume: %w", err)
    }

    for {
        select {
        case <-ctx.Done():
            return nil
        case msg, ok := <-msgs:
            if !ok {
                return fmt.Errorf("canal fechado")
            }
            processWithRetry(ctx, pool, msg, 5)
        }
    }
}

func processWithRetry(ctx context.Context, pool *pgxpool.Pool, msg amqp.Delivery, maxRetries int) {
    messageID := msg.MessageId
    if messageID == "" {
        slog.Error("mensagem sem MessageId — rejeitando permanentemente")
        _ = msg.Reject(false) // false = não requeue
        return
    }

    var lastErr error
    for attempt := 0; attempt <= maxRetries; attempt++ {
        if attempt > 0 {
            delay := backoffWithJitter(attempt)
            slog.Info("retry", "attempt", attempt, "delay", delay, "message_id", messageID)
            select {
            case <-ctx.Done():
                return
            case <-time.After(delay):
            }
        }

        lastErr = processIdempotent(ctx, pool, msg)
        if lastErr == nil {
            _ = msg.Ack(false)
            return
        }

        slog.Error("falha no processamento", "attempt", attempt, "err", lastErr)
    }

    slog.Error("max retries excedido, rejeitando para DLQ", "message_id", messageID)
    _ = msg.Reject(false) // vai para DLQ se configurada no broker
}

func processIdempotent(ctx context.Context, pool *pgxpool.Pool, msg amqp.Delivery) error {
    var event OrderPlacedEvent
    if err := json.Unmarshal(msg.Body, &event); err != nil {
        return fmt.Errorf("unmarshal: %w", err) // não retentável
    }

    tx, err := pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    // Deduplicação: INSERT ON CONFLICT — atômico com o efeito de negócio
    tag, err := tx.Exec(ctx,
        `INSERT INTO processed_messages (message_id, processed_at)
         VALUES ($1, NOW())
         ON CONFLICT (message_id) DO NOTHING`,
        msg.MessageId,
    )
    if err != nil {
        return fmt.Errorf("dedup insert: %w", err)
    }

    if tag.RowsAffected() == 0 {
        // Já processado — commit vazio e sai
        slog.Info("mensagem já processada", "message_id", msg.MessageId)
        return tx.Commit(ctx)
    }

    // Efeito de negócio
    _, err = tx.Exec(ctx,
        `INSERT INTO inventory_reservations (order_id, reserved_at)
         VALUES ($1, NOW())
         ON CONFLICT (order_id) DO NOTHING`,
        event.OrderID,
    )
    if err != nil {
        return fmt.Errorf("reservar estoque: %w", err)
    }

    return tx.Commit(ctx)
}

func backoffWithJitter(attempt int) time.Duration {
    base := math.Pow(2, float64(attempt))
    capped := math.Min(base, 60) // máximo 60s
    jitter := capped * (0.5 + rand.Float64()*0.5)
    return time.Duration(jitter * float64(time.Second))
}

ON CONFLICT DO NOTHING retorna RowsAffected() == 0 em duplicata — isso distingue o processamento bem-sucedido de uma deduplicação sem precisar de query prévia. O defer tx.Rollback(ctx) é seguro — o pgx ignora o rollback se a transação já foi commitada.

Exercícios práticos

Decisões de engenharia

At-most-once vs At-least-once vs Exactly-once

At-most-once quando perder mensagens é aceitável e latência é crítica: métricas de telemetria, heartbeats, logs de baixa prioridade. Sem retry, sem overhead de ACK.

At-least-once é o padrão de produção na maioria dos sistemas: Kafka, RabbitMQ, SQS Standard. Requer que consumidores sejam idempotentes — o sistema aceita duplicatas mas garante que a mensagem sempre será processada.

Exactly-once só é viável dentro de ecossistemas controlados (Kafka Streams para transformações Kafka→Kafka). Para qualquer saída externa (banco, API, email), você está de volta ao at-least-once com idempotência. Não prometa exactly-once para stakeholders — prometa "sem duplicação visível ao usuário" via idempotência.

Idempotency Key na API vs Idempotent Consumer no broker

Idempotency Key na API REST (modelo Stripe/AWS): o cliente gera um UUID antes da chamada e o inclui no header. O servidor armazena o resultado por um TTL e retorna o mesmo resultado em chamadas repetidas. Ideal para APIs síncronas onde o cliente faz retry e não quer efeito duplo. O cliente controla a deduplicação.

Idempotent Consumer no broker: o consumidor mantém uma tabela de message IDs processados e ignora reentregas. Ideal para processamento assíncrono onde o broker pode reenviar mensagens sem aviso. O consumidor controla a deduplicação usando o ID da mensagem como chave. Pode-se usar o Inbox Pattern (tabela de inbox com constraint única) para garantir atomicidade entre o registro e o processamento.

Deduplicação por ID vs por hash de conteúdo

Deduplicação por ID único (MessageDeduplicationId, Idempotency-Key): o produtor/cliente gera e controla o ID. É determinística se o mesmo ID sempre representa a mesma intenção. Funciona mesmo que o conteúdo seja diferente (ex: retry após enriquecimento parcial). Depende de o produtor gerar IDs consistentes.

Deduplicação por hash de conteúdo (ContentBasedDeduplication do SQS FIFO): o broker calcula o hash SHA-256 do corpo. Transparente para o produtor — não precisa gerar IDs. Mas falha quando mensagens legítimas e diferentes têm o mesmo conteúdo (ex: dois "decrementar saldo em R$10" para contas diferentes). Use por hash apenas quando o conteúdo é globalmente único ou quando o produtor não pode gerar IDs estáveis.

Retry com backoff vs envio imediato para DLQ

Retry com backoff exponencial + jitter é adequado para falhas transitórias: timeout de rede, serviço temporariamente indisponível, rate limit. O backoff exponencial aumenta o intervalo a cada tentativa; o jitter distribui os retries aleatoriamente para evitar thundering herd (100 consumidores falhando ao mesmo tempo e retentando simultaneamente). Defina max_attempts e max_delay explicitamente.

DLQ imediata é adequada para falhas permanentes: mensagem malformada, esquema inválido, erro de validação de negócio que nunca será corrigido pelo tempo. Enviar para DLQ imediatamente evita travar o consumer com mensagens poison. Configure circuit breaker: depois de N falhas em janela T, pare de consumir e alerte. A DLQ precisa de monitoramento ativo e processo de replay — não é um lixão.

  1. Crie um consumidor deliberadamente não-idempotente (incremento de saldo) e demonstre a duplicação: publique a mesma mensagem 3 vezes e observe o saldo final incorreto. Em seguida, corrija com idempotency key + tabela de processados e repita o experimento.
    Critério: sem idempotência, saldo final é 3× o esperado; com idempotência, saldo é exatamente 1× mesmo com 3 entregas; o fix deve usar constraint de unicidade no banco, não checagem em memória.
  2. Implemente o padrão de Idempotency-Key em uma API REST de pagamento: gere a chave no cliente antes de chamar, armazene o resultado por 24h, e retorne o resultado cacheado em chamadas subsequentes com a mesma chave. Valide que nenhuma cobrança dupla ocorre mesmo com retries agressivos.
    Critério: 100 chamadas paralelas com a mesma chave resultam em exatamente 1 cobrança; a resposta das 99 repetições é idêntica à resposta original (mesmo status code, mesmo body).
  3. Configure o SQS FIFO com MessageDeduplicationId baseado no hash SHA-256 do conteúdo. Publique 10 mensagens idênticas em sequência e verifique que o consumidor recebe apenas uma. Em seguida, modifique um campo e observe que é tratada como nova mensagem. Teste o comportamento na janela de deduplicação de 5 minutos.
    Critério: 10 mensagens idênticas → 1 entrega ao consumidor; após 5 minutos, a mesma mensagem é entregue novamente; mensagem com campo diferente gera entrega adicional.
  4. Implemente backoff exponencial com full jitter e meça a distribuição dos retries: com 100 clientes simultâneos falhando ao mesmo tempo, compare o pico de QPS no servidor com jitter vs sem jitter. Use um histograma para visualizar a diferença.
    Critério: histograma mostra distribuição uniforme com jitter vs spike concentrado sem jitter; pico de QPS com jitter é pelo menos 10× menor que sem jitter no cenário de 100 clientes simultâneos.
  5. Simule uma DLQ real: configure o RabbitMQ com x-dead-letter-exchange, faça um consumidor falhar intencionalmente após 3 tentativas, inspecione as mensagens na DLQ incluindo os headers de rastreamento (x-death), e implemente um consumer de DLQ que alerta e registra os erros com contexto suficiente para diagnóstico.
    Critério: mensagem aparece na DLQ após exatamente 3 tentativas; o consumer de DLQ registra queue de origem, timestamp de cada tentativa, e motivo da falha; um alerta é disparado quando DLQ ultrapassa 10 mensagens.

Perguntas de entrevista

    O que exatamente significa "exactly-once" e por que é tão difícil de garantir em sistemas distribuídos?

    Exactly-once delivery significa que a mensagem é entregue ao consumidor exatamente uma vez, sem perda nem duplicata. Exactly-once processing significa que o efeito de negócio da mensagem ocorre exatamente uma vez, mesmo que a mensagem seja entregue mais de uma vez.

    Por que é difícil: o Two Generals Problem demonstra que em redes não confiáveis, você não pode ter certeza absoluta que uma mensagem foi recebida sem uma confirmação, e a confirmação também pode se perder. Se o consumidor processa a mensagem e morre antes de dar ACK, o broker reenvia — o processamento ocorreu duas vezes.

    Kafka Exactly-Once: dentro do ecossistema Kafka (produtor idempotente + transações Kafka), é possível exactly-once de forma end-to-end — mas apenas quando a saída também é Kafka. O produtor usa PID + sequence number para deduplicação no broker. Para qualquer saída externa (banco, API, email), você volta ao problema original — o estado externo pode ter sido modificado antes do crash.

    Na prática: a solução real não é exactly-once delivery, mas "exactly-once semantics" via idempotência. Aceite at-least-once; garanta que processar a mesma mensagem N vezes tem o mesmo efeito que processar 1 vez. Isso é mais robusto e prático que tentar garantir exactly-once delivery.

    Como você implementa um consumer idempotente na prática? Quais são as armadilhas comuns?

    Abordagem 1 — Tabela de IDs processados: antes de processar, verifica se o message ID já está na tabela processed_messages. Se sim, ignora. Se não, processa e insere. A checagem e inserção devem ser atômicas (mesma transação) para evitar race condition entre duas instâncias do consumidor processando a mesma mensagem simultaneamente.

    Abordagem 2 — Operações naturalmente idempotentes: usar INSERT OR IGNORE, UPSERT, ou UPDATE ... WHERE status = 'pending' em vez de INSERT seguido de UPDATE. Uma operação de database que verifica pré-condições é naturalmente idempotente.

    Abordagem 3 — Inbox Pattern: inserir o message ID em tabela de inbox com PRIMARY KEY constraint. A segunda inserção viola a constraint e aborta. O processamento só ocorre se a inserção foi bem-sucedida. Garante atomicidade.

    Armadilhas comuns: (1) checagem em memória em vez de banco — falha com múltiplas instâncias; (2) não ter message ID único — o broker precisa fornecer ou você precisa gerar no produtor; (3) TTL de IDs processados muito curto — mensagens que chegam tarde do que o TTL são processadas novamente; (4) não incluir efeitos colaterais externos (chamadas a APIs) na idempotência — a API foi chamada duas vezes mesmo que o banco esteja ok.

    Qual a diferença entre retry no produtor e retry no consumidor? Quando cada um é adequado?

    Retry no produtor: o cliente/produtor reenvia a mensagem quando não recebe confirmação do broker. Protege contra falhas de rede entre produtor e broker. No Kafka, o produtor idempotente (acks=all + retries altos) garante at-least-once com deduplicação no broker via sequence numbers. Necessário quando a perda da mensagem antes de chegar ao broker é inaceitável.

    Retry no consumidor: o consumidor falhou ao processar a mensagem (erro de negócio, dependência indisponível) e o broker a reentrega. Protege contra falhas de processamento. O consumidor não dá ACK → o broker reenvia após o visibility timeout (SQS) ou o consumidor volta ao offset anterior (Kafka). Usado quando o erro é transitório.

    Quando usar cada um: retry no produtor é quase sempre necessário em sistemas at-least-once. Retry no consumidor é para erros de processamento transitórios — use com backoff exponencial e max_attempts para evitar loop infinito. Para erros permanentes (mensagem malformada), não faça retry: envie diretamente para DLQ. A distinção "é um erro transitório ou permanente?" é a decisão central no design do retry do consumidor.

    O que é DLQ e qual deve ser a estratégia de monitoramento e replay?

    DLQ (Dead Letter Queue): fila ou tópico para onde mensagens são enviadas após falhar além do número máximo de tentativas. Serve como safety net — mensagens não são perdidas, ficam na DLQ aguardando análise e replay.

    Quando enviar para DLQ: (1) após N tentativas com backoff — erro persistente, provavelmente não transitório; (2) imediatamente em caso de erro de parsing/validação de esquema — retry nunca vai funcionar; (3) quando o consumidor detecta mensagem poison (causa crash do processo).

    Monitoramento: DLQ deve ter alarme ativo — qualquer mensagem na DLQ é um bug ou anomalia que exige investigação. Monitore: tamanho da DLQ, taxa de chegada, mensagens mais antigas. Inclua nos headers de cada mensagem: fila de origem, número de tentativas, timestamp de cada tentativa, motivo da falha, stack trace.

    Replay: deve ser processo controlado e seguro. Antes de reprocessar, corrija o bug que causou a falha. Replaye com rate limiting para não sobrecarregar o sistema. Mantenha o histórico da DLQ — não apague mensagens imediatamente após replay bem-sucedido. Para Kafka, use consumer groups separados para replay a fim de não afetar o consumer de produção.

    Como implementar Idempotency-Key em uma API de pagamentos? O que fazer quando a mesma chave chega com conteúdo diferente?

    Fluxo padrão: (1) cliente gera UUID v4 antes de qualquer chamada; (2) inclui no header Idempotency-Key: {uuid}; (3) servidor verifica na tabela idempotency_keys (key, response_status, response_body, created_at, expires_at); (4) se chave existe e não expirou, retorna resposta armazenada; (5) se chave é nova, processa e armazena resultado atomicamente; (6) TTL típico: 24h a 7 dias para pagamentos.

    Armazenamento atômico: inserir a chave + resultado em uma única transação. Usar INSERT ... ON CONFLICT DO NOTHING com RETURNING para detectar se foi inserida agora ou já existia. Nunca verificar + inserir em operações separadas — race condition entre duas requisições simultâneas com a mesma chave.

    Chave com conteúdo diferente: retorne 422 Unprocessable Entity com mensagem clara — a mesma Idempotency-Key deve sempre representar a mesma requisição. A Stripe retorna idempotency_error com resource_mismatch. Isso protege contra bugs no cliente que reutilizam chaves para operações diferentes.

    Chave em processamento: se a chave existe mas a resposta ainda não foi armazenada (outra requisição está processando), retorne 409 Conflict — cliente deve aguardar e tentar novamente em instantes, não com a mesma chave mas aguardando a conclusão da original.

Referências

  1. artigo Exponential Backoff and Jitter — Marc Brooker, AWS Architecture Blog (2015). aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter — O artigo definitivo sobre jitter em backoff, com análise quantitativa comparando as variantes. "Full jitter" é demonstrado ser significativamente superior a outras abordagens.
  2. artigo Idempotent Consumer — Chris Richardson, microservices.io. microservices.io/patterns/communication/idempotent-consumer.html — Descrição estruturada do padrão com diagrama de sequência, consequências e implementação. Parte do catálogo de padrões de microsserviços.
  3. artigo Stripe Idempotent Requests — Stripe Developer Documentation. stripe.com/docs/api/idempotent_requests — Como a Stripe implementa idempotency keys na prática: TTL, comportamento em conflitos, erros específicos. Referência de implementação de API de pagamento de classe mundial.
  4. livro Designing Data-Intensive Applications — Martin Kleppmann (O'Reilly, 2017). Capítulo 9 (Consistency and Consensus) e capítulo 11 (Stream Processing) cobrem as garantias de entrega e exactly-once com precisão formal. A análise do two generals problem é fundamental.
  5. docs Amazon SQS — Message deduplication — AWS Documentation. docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues-exactly-once-processing.html — Detalhes da janela de deduplicação de 5 minutos do FIFO, MessageDeduplicationId, e comparação com ContentBasedDeduplication.
  6. artigo You Cannot Have Exactly-Once Delivery — Mathias Verraes (2015). mathiasverraes.cqrs.nu/2015/04/exactly-once-is-not-real — Argumenta formalmente que exactly-once delivery não existe em sistemas distribuídos e que a solução real é idempotência. Artigo curto e preciso que desmistifica promessas de vendors.
  7. blog Implementing Idempotent APIs — Brandur Leach. brandur.org/idempotency-keys — Implementação detalhada de Idempotency-Key em APIs com Postgres: esquema da tabela, rocket science de race conditions, TTL, resposta em conflito de chave. A referência mais prática disponível sobre o padrão.
  8. padrão The Idempotency-Key HTTP Header Field (draft) — IETF. datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header — Rascunho de padronização do header Idempotency-Key como campo HTTP oficial. Define semântica, lifecycle, e comportamento esperado de servidores. Mostra que o padrão está se tornando parte da infraestrutura HTTP.
  9. docs Kafka Producer Idempotence — KIP-98 — Apache Kafka. cwiki.apache.org/confluence/display/KAFKA/KIP-98+-+Exactly+Once+Delivery+and+Transactional+Messaging — O KIP que adicionou exactly-once ao Kafka via produtor idempotente (PID + sequence numbers) e transações. Explica o protocolo de 2PC interno e as limitações. Leitura obrigatória para entender o que "exactly-once no Kafka" realmente garante.
  10. blog Exponential Backoff and Jitter — Deep Dive — Marc Brooker, AWS (2022). marcbrooker.com/2022/02/02/jitter.html — Análise matemática aprofundada das variantes de jitter (full, equal, decorrelated) com simulações. Explica por que "decorrelated jitter" é frequentemente melhor que "full jitter" em sistemas com muitos clientes simultâneos.
  11. artigo Pattern: Dead Letter Channel — Gregor Hohpe, Enterprise Integration Patterns. enterpriseintegrationpatterns.com/patterns/messaging/DeadLetterChannel.html — Descrição formal do Dead Letter Channel como padrão de integração. Cobre como mensagens chegam à DLQ, o que incluir nos metadados, e estratégias de monitoramento e recuperação.
  12. docs RabbitMQ — Dead Letter Exchanges — RabbitMQ Documentation. rabbitmq.com/dlx.html — Configuração de DLX e DLQ no RabbitMQ: x-dead-letter-exchange, x-dead-letter-routing-key, e headers x-death adicionados automaticamente. Inclui cenários de dead-lettering: rejection, TTL expirado, e queue lotada.