Dead Letter Queue #

Setiap sistem async yang cukup besar pada akhirnya akan menemui satu jenis pesan yang tidak bisa diproses — bukan karena downstream sedang down sementara, tapi karena ada sesuatu yang salah secara fundamental dengan pesan itu sendiri. Field yang null padahal wajib ada. Format JSON yang tidak sesuai schema. Data yang melanggar constraint database. Logic bug yang membuat consumer selalu panic. Tanpa mekanisme yang tepat, pesan-pesan ini akan stuck di queue, diambil ulang terus-menerus, menghabiskan resource, dan berpotensi memblokir pesan lain yang seharusnya bisa diproses. Ini adalah poison message — dan Dead Letter Queue (DLQ) adalah cara yang benar untuk menanganinya. DLQ bukan hanya tempat karantina untuk pesan bermasalah, melainkan komponen arsitektur yang membuat sistem async menjadi observable, maintainable, dan tidak kehilangan data meski ada kegagalan. Panduan ini membahas DLQ dari konsep dasarnya, implementasi konkret di AWS SQS dan Kafka, strategi reprocessing, monitoring, hingga anti-pattern yang sering ditemui.

Masalah yang DLQ Selesaikan #

Untuk memahami mengapa DLQ penting, perlu dipahami dua jenis kegagalan yang berbeda dalam sistem queue.

Transient failure adalah kegagalan sementara yang sembuh dengan sendirinya: downstream service sedang restart, koneksi database sesaat terputus, rate limit yang sesaat terlampaui. Retry dengan backoff yang tepat adalah solusinya.

Permanent failure adalah kegagalan yang tidak akan sembuh betapapun banyaknya retry: data invalid, bug logic di consumer, schema mismatch antara producer dan consumer, atau pelanggaran business rule. Tidak ada gunanya terus mencoba pesan seperti ini.

Tanpa DLQ — poison message scenario:

t=0    Pesan masuk queue: {"order_id": "123", "amount": null}
t=1    Consumer ambil pesan → panic: cannot convert null to float64
       Consumer tidak delete pesan → visibility timeout = 30s
t=31   Pesan muncul kembali di queue (receive count = 2)
t=32   Consumer ambil lagi → panic lagi → visibility timeout
t=62   Pesan muncul lagi (receive count = 3) ... dan seterusnya
       ...
t=∞    Pesan ini akan diproses berulang selamanya
       → Resource terbuang
       → Queue head-of-line blocking (jika FIFO)
       → Alert false positive membanjiri on-call engineer
       → Pesan valid di belakangnya mungkin terlambat diproses


Dengan DLQ:

t=0    Pesan masuk queue: {"order_id": "123", "amount": null}
t=1    Consumer gagal → visibility timeout
t=31   Consumer gagal lagi → receive count = 2
t=61   Consumer gagal lagi → receive count = 3 = maxReceiveCount
t=62   SQS otomatis pindahkan ke DLQ → main queue bersih
       → Alert: "DLQ has messages" → engineer investigasi
       → Bug ditemukan dan diperbaiki
       → Pesan di DLQ di-replay setelah fix

Perbedaan kunci: tanpa DLQ, kegagalan tersembunyi dan terus menghabiskan resource. Dengan DLQ, kegagalan terekspose dan bisa ditangani secara terstruktur.


Konsep Dasar DLQ #

DLQ adalah queue terpisah yang dirancang khusus untuk menampung pesan-pesan yang melewati batas maximum receive count tanpa berhasil diproses. Karakteristik utamanya:

Properti DLQ:

  Terpisah dari main queue
    → Pesan bermasalah tidak mencampuri alur normal
    → Consumer utama tidak perlu tahu DLQ ada

  Menyimpan pesan tanpa loss
    → Payload original tetap intact
    → Metadata tambahan: receive count, timestamp gagal

  Tidak diproses secara otomatis
    → Membutuhkan keputusan manusia atau workflow khusus
    → Engineer bisa inspect sebelum memutuskan apa yang dilakukan

  Retention lebih panjang
    → Main queue: 4 hari (default SQS)
    → DLQ: 7–14 hari untuk memberi waktu investigasi

  Dimonitor secara aktif
    → Pesan di DLQ adalah sinyal ada masalah
    → Alert harus langsung firing ketika ada pesan masuk DLQ

Implementasi di AWS SQS #

SQS menyediakan DLQ secara native melalui Redrive Policy — konfigurasi di main queue yang menentukan ke DLQ mana pesan gagal dikirim, dan berapa kali boleh gagal sebelum dipindahkan.

Setup dengan AWS CLI / Terraform #

# Step 1: Buat DLQ terlebih dahulu
aws sqs create-queue \
  --queue-name order-created-dlq \
  --attributes '{
    "MessageRetentionPeriod": "1209600",
    "VisibilityTimeout": "30"
  }'
# MessageRetentionPeriod = 14 hari (1209600 detik)

# Step 2: Dapatkan ARN DLQ
DLQ_ARN=$(aws sqs get-queue-attributes \
  --queue-url https://sqs.ap-southeast-1.amazonaws.com/123456/order-created-dlq \
  --attribute-names QueueArn \
  --query 'Attributes.QueueArn' --output text)

# Step 3: Buat main queue dengan redrive policy
aws sqs create-queue \
  --queue-name order-created \
  --attributes "{
    \"VisibilityTimeout\": \"30\",
    \"RedrivePolicy\": \"{\\\"deadLetterTargetArn\\\":\\\"$DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"
  }"

Atau dengan Terraform yang lebih mudah dibaca:

# Terraform — DLQ setup
resource "aws_sqs_queue" "order_dlq" {
  name                      = "order-created-dlq"
  message_retention_seconds = 1209600  # 14 hari
  visibility_timeout_seconds = 30

  tags = {
    Environment = "production"
    Purpose     = "dead-letter-queue"
  }
}

resource "aws_sqs_queue" "order_main" {
  name                       = "order-created"
  visibility_timeout_seconds = 30
  message_retention_seconds  = 345600  # 4 hari

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.order_dlq.arn
    maxReceiveCount     = 5  # Pindah ke DLQ setelah 5 kali gagal
  })
}

# CloudWatch alarm — alert langsung saat ada pesan di DLQ
resource "aws_cloudwatch_metric_alarm" "dlq_not_empty" {
  alarm_name          = "order-dlq-has-messages"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "ApproximateNumberOfMessagesVisible"
  namespace           = "AWS/SQS"
  period              = 60
  statistic           = "Sum"
  threshold           = 0
  alarm_description   = "Messages found in DLQ — immediate investigation required"

  dimensions = {
    QueueName = aws_sqs_queue.order_dlq.name
  }

  alarm_actions = [aws_sns_topic.alerts.arn]
}

Enriching Pesan Sebelum Masuk DLQ #

Salah satu pola yang sangat berguna adalah menambahkan konteks kegagalan ke pesan sebelum membiarkannya masuk DLQ, alih-alih hanya mengandalkan metadata SQS bawaan.

// Consumer yang mengirim pesan ke DLQ dengan context lengkap
type DLQEnrichedMessage struct {
    OriginalPayload  json.RawMessage `json:"original_payload"`
    FailureReason    string          `json:"failure_reason"`
    FailureType      string          `json:"failure_type"` // "permanent" | "transient"
    AttemptCount     int             `json:"attempt_count"`
    FirstFailedAt    time.Time       `json:"first_failed_at"`
    LastFailedAt     time.Time       `json:"last_failed_at"`
    ConsumerVersion  string          `json:"consumer_version"`
    CorrelationID    string          `json:"correlation_id"`
    EnvironmentInfo  map[string]string `json:"env_info"`
}

func (c *OrderConsumer) handleMessage(ctx context.Context, msg *sqs.Message) error {
    var order OrderCreatedEvent
    if err := json.Unmarshal([]byte(*msg.Body), &order); err != nil {
        // Error permanent: payload tidak bisa di-parse sama sekali
        // Enrich dan fail fast — tidak perlu retry, langsung ke DLQ
        return c.sendToDLQWithContext(ctx, msg, err, "permanent", "json_parse_error")
    }

    if err := c.processOrder(ctx, order); err != nil {
        receiveCount := getReceiveCount(msg)

        if isPermanentError(err) {
            // Bug logic, constraint violation, data invalid
            // Langsung kirim ke DLQ dengan informasi lengkap
            return c.sendToDLQWithContext(ctx, msg, err, "permanent", classifyError(err))
        }

        // Error transient — biarkan SQS retry (tidak delete message)
        // Setelah maxReceiveCount tercapai, SQS akan otomatis pindahkan ke DLQ
        log.Warnf("transient error on attempt %d for order %s: %v",
            receiveCount, order.OrderID, err)
        return err // return error = jangan delete message = SQS akan retry
    }

    // Sukses — delete message dari queue
    return c.sqs.DeleteMessage(ctx, &sqs.DeleteMessageInput{
        QueueUrl:      &c.queueURL,
        ReceiptHandle: msg.ReceiptHandle,
    })
}

func (c *OrderConsumer) sendToDLQWithContext(ctx context.Context, msg *sqs.Message,
    err error, failureType, errorClass string) error {

    enriched := DLQEnrichedMessage{
        OriginalPayload: json.RawMessage(*msg.Body),
        FailureReason:   err.Error(),
        FailureType:     failureType,
        AttemptCount:    getReceiveCount(msg),
        LastFailedAt:    time.Now(),
        ConsumerVersion: c.version,
        CorrelationID:   getCorrelationID(msg),
        EnvironmentInfo: map[string]string{
            "error_class":    errorClass,
            "consumer_host":  os.Hostname(),
            "consumer_go_version": runtime.Version(),
        },
    }

    enrichedBytes, _ := json.Marshal(enriched)

    _, sendErr := c.dlqSQS.SendMessage(ctx, &sqs.SendMessageInput{
        QueueUrl:    &c.dlqURL,
        MessageBody: aws.String(string(enrichedBytes)),
    })

    if sendErr != nil {
        log.Criticalf("FAILED to send to DLQ — message will be lost: %v", sendErr)
        return sendErr
    }

    // Delete dari main queue setelah berhasil masuk DLQ
    c.sqs.DeleteMessage(ctx, &sqs.DeleteMessageInput{
        QueueUrl:      &c.queueURL,
        ReceiptHandle: msg.ReceiptHandle,
    })

    log.Warnf("message sent to DLQ [%s]: %s", errorClass, err.Error())
    return nil // return nil = jangan retry lagi
}

DLQ di Kafka #

Kafka tidak memiliki DLQ bawaan seperti SQS, tapi pola yang setara bisa diimplementasikan dengan dedicated retry topics dan DLQ topic.

Kafka DLQ Pattern:

Normal flow:
  order-events topic → Consumer → sukses → commit offset

Error flow:
  order-events topic → Consumer → gagal (transient)
                           ↓
                    order-events-retry-1 topic (delay: 1s)
                           ↓ masih gagal
                    order-events-retry-2 topic (delay: 5s)
                           ↓ masih gagal
                    order-events-retry-3 topic (delay: 30s)
                           ↓ masih gagal
                    order-events-dlq topic (tidak diproses otomatis)
// Kafka consumer dengan retry topic pattern
type KafkaConsumerWithDLQ struct {
    mainConsumer  *kafka.Consumer
    retryProducer *kafka.Producer
    dlqProducer   *kafka.Producer
    maxRetries    int
}

func (c *KafkaConsumerWithDLQ) processMessage(msg *kafka.Message) error {
    // Baca retry count dari header
    retryCount := getRetryCountFromHeader(msg)

    var order OrderCreatedEvent
    if err := json.Unmarshal(msg.Value, &order); err != nil {
        // Error permanent — langsung ke DLQ
        return c.sendToKafkaDLQ(msg, err, "json_parse_error")
    }

    if err := c.processOrder(order); err != nil {
        if isPermanentError(err) {
            return c.sendToKafkaDLQ(msg, err, classifyError(err))
        }

        if retryCount >= c.maxRetries {
            return c.sendToKafkaDLQ(msg, err, "max_retries_exceeded")
        }

        // Kirim ke retry topic dengan delay yang sesuai
        return c.sendToRetryTopic(msg, err, retryCount+1)
    }

    return nil
}

func (c *KafkaConsumerWithDLQ) sendToKafkaDLQ(msg *kafka.Message,
    err error, reason string) error {

    dlqMsg := &kafka.Message{
        TopicPartition: kafka.TopicPartition{
            Topic: &c.dlqTopic,
        },
        Value: msg.Value, // payload original
        Headers: append(msg.Headers,
            kafka.Header{Key: "dlq_reason", Value: []byte(reason)},
            kafka.Header{Key: "dlq_error", Value: []byte(err.Error())},
            kafka.Header{Key: "dlq_timestamp", Value: []byte(time.Now().Format(time.RFC3339))},
            kafka.Header{Key: "original_topic", Value: []byte(*msg.TopicPartition.Topic)},
            kafka.Header{Key: "original_offset", Value: []byte(
                strconv.FormatInt(int64(msg.TopicPartition.Offset), 10))},
        ),
    }

    return c.dlqProducer.Produce(dlqMsg, nil)
}

Tiga Strategi Penanganan Pesan di DLQ #

Setelah pesan masuk DLQ, ada tiga pendekatan yang bisa diambil tergantung jenis masalahnya.

1. Inspect dan Replay Setelah Fix #

Untuk kasus di mana ada bug di consumer yang sudah diperbaiki:

// DLQ Reprocessor — kirim ulang ke main queue setelah bug fix
type DLQReprocessor struct {
    dlqURL     string
    mainURL    string
    sqsClient  *sqs.Client
    batchSize  int
    rateLimit  int // pesan per detik
}

func (r *DLQReprocessor) ReplayAll(ctx context.Context, dryRun bool) (*ReplayResult, error) {
    result := &ReplayResult{StartedAt: time.Now()}
    limiter := rate.NewLimiter(rate.Limit(r.rateLimit), r.batchSize)

    log.Infof("starting DLQ replay (dry_run=%v, rate=%d/s)", dryRun, r.rateLimit)

    for {
        msgs, err := r.sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
            QueueUrl:            &r.dlqURL,
            MaxNumberOfMessages: int32(r.batchSize),
            WaitTimeSeconds:     5,
        })
        if err != nil {
            return result, err
        }
        if len(msgs.Messages) == 0 {
            break // DLQ sudah kosong
        }

        for _, msg := range msgs.Messages {
            if err := limiter.Wait(ctx); err != nil {
                return result, err
            }

            // Parse enriched message untuk mendapat original payload
            var enriched DLQEnrichedMessage
            originalPayload := *msg.Body
            if err := json.Unmarshal([]byte(*msg.Body), &enriched); err == nil {
                originalPayload = string(enriched.OriginalPayload)
            }

            if dryRun {
                log.Infof("[DRY RUN] would replay: %s...", truncate(originalPayload, 100))
                result.DryRunCount++
                continue
            }

            // Kirim kembali ke main queue
            _, err := r.sqsClient.SendMessage(ctx, &sqs.SendMessageInput{
                QueueUrl:    &r.mainURL,
                MessageBody: &originalPayload,
                MessageAttributes: map[string]sqsTypes.MessageAttributeValue{
                    "replayed_from_dlq": {
                        DataType:    aws.String("String"),
                        StringValue: aws.String(time.Now().Format(time.RFC3339)),
                    },
                },
            })

            if err != nil {
                result.Failed++
                log.Errorf("failed to replay message: %v", err)
                continue
            }

            // Delete dari DLQ setelah berhasil di-replay
            r.sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
                QueueUrl:      &r.dlqURL,
                ReceiptHandle: msg.ReceiptHandle,
            })

            result.Replayed++
        }
    }

    result.Duration = time.Since(result.StartedAt)
    log.Infof("replay complete: replayed=%d failed=%d dry_run=%d duration=%v",
        result.Replayed, result.Failed, result.DryRunCount, result.Duration)
    return result, nil
}

2. Filter dan Proses Selektif #

Untuk kasus di mana hanya subset pesan yang valid untuk di-replay:

// Replay hanya pesan dengan kriteria tertentu
func (r *DLQReprocessor) ReplayFiltered(ctx context.Context,
    filter func(DLQEnrichedMessage) bool) error {

    processed := 0
    for {
        msgs, _ := r.sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
            QueueUrl:            &r.dlqURL,
            MaxNumberOfMessages: 10,
        })
        if len(msgs.Messages) == 0 {
            break
        }

        for _, msg := range msgs.Messages {
            var enriched DLQEnrichedMessage
            json.Unmarshal([]byte(*msg.Body), &enriched)

            if !filter(enriched) {
                // Pesan ini tidak memenuhi kriteria — skip (jangan delete)
                continue
            }

            // Proses atau replay pesan yang lolos filter
            r.replaySingleMessage(ctx, msg, enriched)
            processed++
        }
    }

    log.Infof("filtered replay complete: %d messages processed", processed)
    return nil
}

// Contoh penggunaan: hanya replay pesan dari error class tertentu
r.ReplayFiltered(ctx, func(msg DLQEnrichedMessage) bool {
    return msg.EnvironmentInfo["error_class"] == "database_timeout"
})

3. Archive dan Discard #

Untuk pesan yang benar-benar tidak bisa diproses ulang (data sudah tidak relevan, atau keputusan bisnis untuk tidak memproses):

// Archive ke S3 sebelum delete — untuk audit trail
func (r *DLQReprocessor) ArchiveAndDiscard(ctx context.Context,
    archiveBucket string) error {

    msgs, _ := r.sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
        QueueUrl:            &r.dlqURL,
        MaxNumberOfMessages: 10,
    })

    for _, msg := range msgs.Messages {
        // Simpan ke S3 untuk audit trail sebelum dihapus
        archiveKey := fmt.Sprintf("dlq-archive/%s/%s.json",
            time.Now().Format("2006/01/02"), *msg.MessageId)

        r.s3.PutObject(ctx, &s3.PutObjectInput{
            Bucket: &archiveBucket,
            Key:    &archiveKey,
            Body:   strings.NewReader(*msg.Body),
        })

        // Delete dari DLQ setelah di-archive
        r.sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
            QueueUrl:      &r.dlqURL,
            ReceiptHandle: msg.ReceiptHandle,
        })

        log.Infof("archived and discarded message %s to s3://%s/%s",
            *msg.MessageId, archiveBucket, archiveKey)
    }

    return nil
}

Menentukan maxReceiveCount yang Tepat #

maxReceiveCount adalah parameter yang paling kritis dan sering salah dikonfigurasi.

Terlalu kecil (1-2):
  → Pesan yang seharusnya berhasil di retry ke-2 malah masuk DLQ
  → False positive di DLQ — banyak noise
  → Cocok untuk: error yang jelas permanent (parse error, schema mismatch)

Tepat (3-5):
  → Cukup retry untuk kegagalan transient
  → Tidak terlalu banyak percobaan yang sia-sia
  → Cocok untuk: sebagian besar use case production

Terlalu besar (10+):
  → Poison message menghabiskan resource terlalu lama sebelum masuk DLQ
  → Delay yang panjang sebelum masalah terdeteksi
  → Queue bisa bottleneck karena satu pesan berulang kali diproses
Panduan pemilihan maxReceiveCount berdasarkan dependency:

  Dependency sangat stabil (internal DB, Redis lokal): 3
  Dependency sedang stabil (microservice internal): 5
  Dependency sering fluktuatif (third-party API): 7-10
  Operasi dengan cold start (Lambda, serverless): 5-8

Anti-Pattern yang Harus Dihindari #

// ✗ Tidak pernah memonitor DLQ — pesan terakumulasi tanpa diketahui
// DLQ yang tidak dimonitor tidak berguna, bahkan berbahaya
// ✓ Selalu pasang CloudWatch alarm / alert saat DLQ > 0 messages

// ✗ Consumer yang menelan semua error dan selalu delete message
func processMessage(msg *sqs.Message) error {
    if err := process(msg); err != nil {
        log.Error(err)
        deleteMessage(msg) // ← pesan hilang, tidak masuk DLQ
        return nil
    }
    deleteMessage(msg)
    return nil
}
// ✓ Return error untuk error transient (jangan delete = SQS akan retry)
// ✓ Kirim manual ke DLQ untuk error permanent, lalu delete dari main queue

// ✗ DLQ diproses otomatis dengan consumer yang sama persis
// Jika consumer yang sama yang menyebabkan masalah, consumer yang sama
// akan gagal lagi memproses pesan dari DLQ
// ✓ DLQ harus diproses dengan mekanisme khusus setelah root cause diperbaiki

// ✗ maxReceiveCount terlalu besar tanpa backoff di visibility timeout
// SQS default visibility timeout 30s — 10 retry = 5 menit pesan berputar
// ✓ Sesuaikan visibility timeout dengan execution time normal +
//   backoff antar retry

// ✗ Menghapus semua pesan DLQ tanpa investigasi
// "DLQ penuh, tinggal hapus saja" — data hilang, bug tidak diketahui
// ✓ Setiap pembersihan DLQ harus didahului analisa root cause

// ✗ DLQ tidak menyimpan context error — tidak bisa debug
// Pesan di DLQ hanya berisi payload original tanpa tahu mengapa gagal
// ✓ Enrich pesan dengan failure_reason, attempt_count, consumer_version
//   sebelum masuk DLQ

Checklist DLQ Production-Ready #

SETUP:
  □ DLQ dibuat sebelum main queue
  □ Redrive policy dikonfigurasi di main queue
  □ maxReceiveCount sesuai karakteristik dependency
  □ DLQ retention lebih panjang dari main queue (7-14 hari)
  □ DLQ memiliki nama yang jelas (nama-main-queue + "-dlq")

MONITORING:
  □ CloudWatch alarm firing saat DLQ > 0 messages
  □ Alert ke channel yang tepat (Slack, PagerDuty)
  □ Dashboard yang menampilkan DLQ depth over time

CONSUMER:
  □ Error transient: tidak delete message (biarkan SQS retry)
  □ Error permanent: kirim ke DLQ manual dengan context lengkap, lalu delete
  □ Semua error di-log dengan correlation ID dan failure reason
  □ consumer_version disertakan dalam metadata DLQ

REPROCESSING:
  □ Ada runbook untuk replay DLQ setelah bug fix
  □ Replay menggunakan rate limiting (jangan flood main queue)
  □ Dry run tersedia sebelum actual replay
  □ Archive ke S3 untuk pesan yang di-discard

ANALISA:
  □ Root cause dicari sebelum replay
  □ Jika ada pattern yang sama, pertimbangkan fix di consumer
  □ DLQ depth trend dianalisa secara periodik

Ringkasan #

  • DLQ adalah safety net wajib untuk sistem async — menangkap pesan yang tidak bisa diproses setelah max retry, mencegah kehilangan data dan infinite loop.
  • Dua jenis kegagalan berbeda: transient (retry dengan backoff) dan permanent (langsung ke DLQ setelah identifikasi) — consumer harus membedakan keduanya secara eksplisit.
  • Enrich pesan sebelum masuk DLQ dengan failure_reason, attempt_count, consumer_version, dan correlation_id — tanpa ini, debugging sangat sulit.
  • maxReceiveCount yang tepat adalah 3–5 untuk sebagian besar kasus; terlalu kecil menghasilkan false positive, terlalu besar membiarkan poison message menghabiskan resource terlalu lama.
  • Tiga strategi penanganan: inspect dan replay setelah fix (paling umum), filter selektif berdasarkan error class, atau archive dan discard dengan audit trail.
  • DLQ harus dimonitor aktif — CloudWatch alarm yang firing segera saat ada pesan di DLQ adalah minimum; DLQ yang tidak dimonitor tidak memiliki nilai.
  • Jangan hapus pesan DLQ tanpa investigasi — setiap pembersihan harus didahului root cause analysis.
  • Replay harus menggunakan rate limiting — jangan kirim semua pesan DLQ ke main queue sekaligus, bisa menciptakan thundering herd.
  • Kafka tidak punya DLQ native — implementasikan dengan retry topics berjenjang dan dedicated DLQ topic dengan consumer terpisah.

← Sebelumnya: Backoff Strategy   Berikutnya: Unit Test →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact