---
title: "Idempotência em Go: Retry e Dead-Letter Queue sem Duplicar Trabalho"
url: "https://golang.com.br/blog/idempotencia-retry-dlq-go/"
markdown_url: "https://golang.com.br/blog/idempotencia-retry-dlq-go.MD"
description: "Aprenda a desenhar consumidores Go idempotentes com PostgreSQL, retry limitado, backoff, dead-letter queue, logs estruturados e testes contra mensagens duplicadas."
date: "2026-05-21"
author: "Golang Brasil"
---

# Idempotência em Go: Retry e Dead-Letter Queue sem Duplicar Trabalho

Aprenda a desenhar consumidores Go idempotentes com PostgreSQL, retry limitado, backoff, dead-letter queue, logs estruturados e testes contra mensagens duplicadas.


Idempotência em Go é o que separa um consumer que funciona no ambiente local de um sistema que aguenta produção. Em filas, eventos e webhooks, a pergunta não é "a mensagem pode chegar duas vezes?". A pergunta correta é: **o que acontece quando ela chegar duas vezes?**

RabbitMQ, Kafka, NATS JetStream, SQS, Redis Streams, Pub/Sub e a maioria dos brokers sérios trabalham, na prática, com alguma variação de entrega "ao menos uma vez". Isso é bom porque reduz perda de mensagens. Mas também significa que o mesmo evento pode ser entregue novamente depois de timeout, queda do processo, falha de rede, rebalanceamento de consumer group ou `ack` que não chegou ao broker.

Este guia mostra como desenhar um consumer Go idempotente, quando fazer retry, quando mandar para dead-letter queue e quais sinais monitorar. Se você ainda está escolhendo tecnologia, leia primeiro o comparativo de [mensageria em Go](/blog/mensageria-go-rabbitmq-kafka-nats-sqs/). Se o problema ainda cabe dentro de um único processo, veja também [worker pool em Go](/blog/worker-pool-go-fila-jobs/).

## O que é idempotência?

Uma operação idempotente pode ser executada mais de uma vez sem mudar o resultado final além da primeira execução. Em HTTP, `PUT` costuma ser idempotente: atualizar o nome de um usuário para "Ana" uma vez ou cinco vezes deixa o mesmo estado. Em mensageria, a ideia é parecida, mas o risco é maior porque a duplicidade pode gerar efeito externo.

Exemplos de efeito perigoso:

- Cobrar o mesmo pedido duas vezes.
- Enviar dois e-mails de confirmação.
- Criar dois registros para a mesma nota fiscal.
- Aplicar duas vezes o mesmo crédito em carteira.
- Reprocessar um webhook e sobrescrever estado mais novo.

O objetivo não é impedir que o broker reenvie mensagem. O objetivo é fazer seu handler reconhecer que aquele evento já foi tratado ou que a transição de estado já aconteceu.

## Use uma chave de idempotência estável

Todo evento precisa de um identificador estável. Pode ser `event_id`, `message_id`, `payment_id`, `order_id + status`, `webhook_id` ou uma chave natural do domínio. O ponto importante é que a chave venha do produtor ou do sistema externo, não seja gerada aleatoriamente pelo consumer a cada tentativa.

Um payload mínimo:

```go
type PedidoPago struct {
    EventID   string    `json:"event_id"`
    PedidoID  string    `json:"pedido_id"`
    Valor     int64     `json:"valor_centavos"`
    PaidAt    time.Time `json:"paid_at"`
}
```

Se `EventID` muda a cada retry, ele não serve para idempotência. Se o provedor externo não envia um ID confiável, derive uma chave determinística com os campos que definem o evento, como `provedor + pagamento_id + status`.

## Tabela de eventos processados

O padrão mais comum em APIs Go com PostgreSQL é registrar eventos processados em uma tabela com chave única. Essa tabela pode ser criada por migration, junto com o restante do schema da aplicação:

```sql
CREATE TABLE processed_events (
    event_id TEXT PRIMARY KEY,
    event_type TEXT NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

Em produção, você também pode guardar `payload_hash`, `source`, `attempts` e `last_error`, mas comece simples. O essencial é ter uma constraint única que o banco consiga proteger mesmo com dois consumers concorrentes.

## Handler idempotente com transação

O desenho seguro é: abrir transação, tentar registrar o evento, executar a regra de negócio e commitar tudo junto. Se o evento já existe, o handler retorna sucesso sem repetir o efeito.

```go
func ProcessarPedidoPago(ctx context.Context, db *sql.DB, evento PedidoPago) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    var inserted bool
    err = tx.QueryRowContext(ctx, `
        INSERT INTO processed_events (event_id, event_type)
        VALUES ($1, $2)
        ON CONFLICT (event_id) DO NOTHING
        RETURNING true
    `, evento.EventID, "pedido_pago").Scan(&inserted)
    if errors.Is(err, sql.ErrNoRows) {
        return tx.Commit()
    }
    if err != nil {
        return err
    }

    if err := marcarPedidoComoPago(ctx, tx, evento.PedidoID, evento.Valor); err != nil {
        return err
    }

    return tx.Commit()
}
```

Esse exemplo usa PostgreSQL porque `ON CONFLICT DO NOTHING` é simples e previsível. A mesma ideia existe em MySQL com `INSERT IGNORE` ou `ON DUPLICATE KEY UPDATE`, mas cuidado para não esconder erro real como se fosse duplicidade.

## Ordem da escrita importa

Uma dúvida comum é se o consumer deve registrar o evento antes ou depois da regra de negócio. Na prática, o registro e a regra precisam estar na mesma transação sempre que possível. Se você marca o evento como processado e depois falha ao atualizar o pedido, a próxima entrega será ignorada indevidamente. Se você atualiza o pedido e falha antes de registrar o evento, a próxima entrega pode repetir o efeito.

Nem todo efeito externo cabe em transação. Enviar e-mail, chamar gateway de pagamento e publicar outro evento exigem desenho adicional. Nesses casos, prefira gravar uma intenção no banco e deixar outro worker executar o efeito externo com sua própria chave de idempotência. Esse padrão é conhecido como outbox e conversa bem com [migrations em Go](/blog/migrations-go-banco-dados-producao/), porque a tabela de outbox vira parte explícita do schema.

## Retry não é tratamento de erro

Retry é uma ferramenta para erro temporário. Ele não corrige payload inválido, contrato quebrado, ID inexistente ou bug de código. Antes de retentar, classifique o erro:

| Tipo de erro | Ação recomendada |
|---|---|
| Timeout de API externa | Retry com backoff |
| Banco indisponível | Retry com backoff |
| Rate limit 429 | Retry respeitando janela |
| JSON inválido | DLQ direto |
| Campo obrigatório ausente | DLQ direto |
| Pedido inexistente definitivo | DLQ ou ignorar com auditoria |
| Bug do handler | Pausar consumer, corrigir e reprocessar |

Um retry sem limite vira loop infinito. Um retry imediato em massa vira tempestade. Um retry sem idempotência vira duplicidade.

## Backoff em Go

Para um worker simples, backoff exponencial com limite resolve bastante:

```go
func retryDelay(attempt int) time.Duration {
    if attempt < 1 {
        attempt = 1
    }
    delay := time.Duration(1<<min(attempt-1, 5)) * time.Second
    jitter := time.Duration(rand.Int63n(int64(250 * time.Millisecond)))
    return delay + jitter
}
```

Em um broker real, prefira recursos duráveis do próprio sistema quando disponíveis: RabbitMQ com exchange de retry e DLX, SQS com visibility timeout e DLQ, Kafka com tópico de retry, NATS JetStream com `NakWithDelay`. Dormir dentro do consumer funciona em baixo volume, mas prende worker e reduz throughput.

## Quando mandar para DLQ

Dead-letter queue não é lixeira. É uma fila de investigação. Uma mensagem deve ir para DLQ quando o sistema decidiu que continuar tentando agora é pior do que separar o caso para análise.

Boas razões para DLQ:

- Payload inválido ou incompatível com schema atual.
- Erro permanente depois de validação de domínio.
- Número máximo de tentativas atingido.
- Dependência externa rejeitou definitivamente a operação.
- Handler detectou estado impossível e precisa de intervenção.

Cada mensagem em DLQ precisa carregar motivo, tentativa, horário, erro resumido e correlação com logs. Se a DLQ cresce e ninguém olha, ela virou perda silenciosa de dados com nome bonito.

## Logs estruturados para consumers

Consumers precisam de logs pesquisáveis. Use `slog` com campos estáveis: `event_id`, `message_id`, `attempt`, `queue`, `consumer`, `pedido_id`, `error_type` e `duration_ms`. Texto livre não basta quando você precisa investigar por que 300 mensagens foram para DLQ.

```go
logger.Warn("mensagem enviada para dlq",
    slog.String("event_id", evento.EventID),
    slog.String("queue", "payments"),
    slog.Int("attempt", attempt),
    slog.String("reason", "payload_invalido"),
    slog.Any("err", err),
)
```

O guia de [slog em Go](/blog/slog-go-logging-estruturado/) mostra como padronizar JSON, níveis e campos comuns para APIs e workers.

## Métricas mínimas

Sem métrica, retry parece sucesso até o atraso virar incidente. Monitore pelo menos:

- Mensagens processadas por segundo.
- Taxa de erro por tipo.
- Número de retries por janela.
- Tamanho da fila e idade da mensagem mais antiga.
- Tamanho da DLQ.
- Tempo p95 de processamento.
- Quantidade de eventos duplicados ignorados.

O contador de duplicados é especialmente útil. Se ele sobe muito, talvez o broker esteja reentregando por timeout curto demais, o consumer esteja demorando além do esperado ou o `ack` esteja acontecendo tarde.

## Como testar idempotência

Teste idempotência como comportamento, não como detalhe de implementação. Rode o mesmo evento duas vezes e verifique que o estado final só mudou uma vez.

```go
func TestProcessarPedidoPagoIdempotente(t *testing.T) {
    ctx := context.Background()
    db := newTestDB(t)
    evento := PedidoPago{EventID: "evt_123", PedidoID: "ped_1", Valor: 9900}

    require.NoError(t, ProcessarPedidoPago(ctx, db, evento))
    require.NoError(t, ProcessarPedidoPago(ctx, db, evento))

    pedido := buscarPedido(t, db, "ped_1")
    assert.Equal(t, "pago", pedido.Status)
    assert.Equal(t, int64(9900), pedido.ValorPago)
    assert.Equal(t, 1, contarEventosProcessados(t, db, "evt_123"))
}
```

Também vale testar corrida com dois goroutines processando o mesmo evento. A constraint única do banco deve proteger o estado mesmo se ambos chegarem ao mesmo tempo.

## Checklist para produção

Antes de colocar um consumer Go em produção, confirme:

- Todo evento tem chave de idempotência estável.
- Efeitos de banco e registro de processamento ficam na mesma transação quando possível.
- Retry tem limite, backoff e classificação de erro.
- DLQ tem dono, alerta e processo de reprocessamento.
- Logs carregam `event_id`, tentativa, fila e motivo do erro.
- Métricas mostram fila, lag, retries, duplicados e DLQ.
- Testes executam o mesmo evento duas vezes.

Esse checklist aparece em entrevistas justamente porque ele vem de incidentes reais. Muitas [vagas Go no Brasil](/vagas/) pedem Kafka, RabbitMQ, SQS, PostgreSQL e Kubernetes, mas o diferencial é explicar como você evita duplicidade, como reprocessa com segurança e como descobre que um consumer está atrasado.

## Conclusão

Idempotência em Go não depende de uma biblioteca mágica. Ela nasce de uma chave estável, uma constraint única, transações bem colocadas, retry limitado, DLQ tratada como operação real e logs que permitem investigar. Depois que esse desenho existe, trocar RabbitMQ por SQS ou Kafka por NATS deixa de ser o centro da discussão.

Comece pelo contrato do evento e pelo estado que não pode duplicar. Depois implemente o handler idempotente. Só então ajuste broker, backoff e throughput. Esse caminho é menos glamouroso do que instalar mais uma ferramenta, mas é o que mantém sistemas Go corretos quando produção entrega a mesma mensagem de novo.
