Kodeloka
  1. Kodeloka
  2. Kelas RabbitMQ
  3. Materi

Modul 7 · Modul 7 dari 15

Dead Letter Exchange, TTL, dan Retry Berjenjang

Membangun mekanisme percobaan ulang yang tidak pernah berputar tanpa henti, dan tempat pembuangan akhir yang benar-benar bisa ditindaklanjuti.

Kapan Pesan Menjadi "Dead Letter"

Sebuah pesan dikirim ke dead letter exchange bila salah satu dari tiga hal terjadi:

tiga pemicutext
1. Ditolak dengan requeue=false
     nack(msg, false, false)  atau  reject(msg, false)

2. Kedaluwarsa (TTL habis)
     dari x-message-ttl pada queue, atau expiration pada pesan

3. Queue penuh (overflow) dengan kebijakan reject-publish-dlx
     atau pesan terdorong keluar oleh drop-head

Tambahan pada quorum queue:
4. Melewati x-delivery-limit  → otomatis di-dead-letter

Menyiapkan DLX

topologi DLX dasarbash
RA="rabbitmqadmin -u admin -p rahasia"

# tempat pembuangan akhir
$RA declare exchange name=pesanan.dlx type=direct durable=true
$RA declare queue    name=pesanan.email.dlq durable=true
$RA declare binding  source=pesanan.dlx destination=pesanan.email.dlq \
                     routing_key=pesanan.email

# queue kerja yang menunjuk ke DLX
$RA declare queue name=pesanan.email durable=true arguments='{
  "x-queue-type": "quorum",
  "x-dead-letter-exchange": "pesanan.dlx",
  "x-dead-letter-routing-key": "pesanan.email",
  "x-delivery-limit": 5
}'
⚠️ Tanpa x-dead-letter-routing-key, routing key asli dipertahankan

Bila tidak diset, pesan masuk ke DLX dengan routing key aslinya — misalnya pesanan.jakarta.dibuat. Bila DLX Anda bertipe direct dan bindingnya bernama pesanan.email, tidak ada yang cocok dan pesan hilang untuk kedua kalinya.

Selalu setel routing key DLX secara eksplisit, atau gunakan DLX bertipe fanout agar apa pun routing key-nya tetap tertangkap.

Header yang Ditambahkan Broker

Saat sebuah pesan di-dead-letter, RabbitMQ menyisipkan header x-death yang berisi riwayat lengkapnya — informasi paling berharga saat menyelidiki isi DLQ.

header x-deathjson
{
  "x-death": [
    {
      "count": 3,
      "reason": "rejected",
      "queue": "pesanan.email",
      "exchange": "pesanan",
      "routing-keys": ["pesanan.jakarta.dibuat"],
      "time": "2026-07-28T09:14:22Z"
    }
  ],
  "x-first-death-reason": "rejected",
  "x-first-death-queue": "pesanan.email",
  "x-first-death-exchange": "pesanan"
}

reason bernilai rejected, expired, maxlen, atau delivery_limit. Ini langsung memberi tahu Anda kenapa pesan berakhir di sini.

Pola Retry dengan Backoff Berjenjang

Ini pola paling berguna dan paling sering salah diterapkan. Idenya: kegagalan sementara diberi kesempatan lagi setelah jeda yang makin panjang, tetapi jumlah percobaannya terbatas.

arsitektur retrytext
                    gagal (nack requeue=false)
  pesanan.email ─────────────────────────> pesanan.dlx
       ▲                                        │ rk = retry.5s
       │                                        ▼
       │                              ┌──────────────────────┐
       │   TTL habis, dead-letter     │ retry.5s             │
       └──────────────────────────────│ x-message-ttl: 5000  │
       │                              │ DLX → pesanan (asal) │
       │                              └──────────────────────┘
       │                                        │ gagal lagi
       │                                        ▼ rk = retry.30s
       │                              ┌──────────────────────┐
       └──────────────────────────────│ retry.30s (30 detik) │
                                      └──────────────────────┘
                                                │ gagal lagi
                                                ▼ rk = retry.5m
                                      ┌──────────────────────┐
                                      │ retry.5m  (5 menit)  │
                                      └──────────────────────┘
                                                │ masih gagal
                                                ▼
                                      ┌──────────────────────┐
                                      │ pesanan.email.dlq    │  manusia
                                      │ (tanpa TTL, mati)    │  memeriksa
                                      └──────────────────────┘
🎯 Kunci pola ini: queue retry TIDAK punya konsumer

Queue retry.5s tidak dikonsumsi siapa pun. Pesan hanya duduk di sana sampai TTL-nya habis, lalu broker sendiri yang memindahkannya kembali ke exchange asal lewat DLX. Penundaan dikerjakan broker, bukan oleh sleep di konsumer Anda — yang akan memblokir worker dan memboroskan koneksi.

membuat tingkat retrybash
RA="rabbitmqadmin -u admin -p rahasia"

for tingkat in "5s:5000" "30s:30000" "5m:300000"; do
  nama="${tingkat%%:*}"; ttl="${tingkat##*:}"
  $RA declare queue name="retry.$nama" durable=true arguments="{
    \"x-message-ttl\": $ttl,
    \"x-dead-letter-exchange\": \"pesanan\",
    \"x-dead-letter-routing-key\": \"pesanan.jakarta.dibuat\"
  }"
  $RA declare binding source=pesanan.dlx destination="retry.$nama" \
                      routing_key="retry.$nama"
done

# DLQ terakhir: tanpa TTL, tanpa DLX — pesan berhenti di sini
$RA declare queue name=pesanan.email.dlq durable=true
$RA declare binding source=pesanan.dlx destination=pesanan.email.dlq \
                    routing_key=habis
konsumer yang memilih tingkat retryjavascript
const TINGKAT = ['retry.5s', 'retry.30s', 'retry.5m'];

function percobaanKe(msg) {
  const d = (msg.properties.headers || {})['x-death'];
  if (!Array.isArray(d) || !d.length) return 0;
  return d.reduce((a, x) => a + (x.count || 0), 0);
}

await ch.consume('pesanan.email', async (msg) => {
  if (!msg) return;
  try {
    await proses(JSON.parse(msg.content.toString()));
    ch.ack(msg);
  } catch (e) {
    if (e.permanen) {                       // payload rusak, skema salah
      ch.publish('pesanan.dlx', 'habis', msg.content, msg.properties);
      ch.ack(msg);
      return;
    }
    const n = percobaanKe(msg);
    const tujuan = n < TINGKAT.length ? TINGKAT[n] : 'habis';
    ch.publish('pesanan.dlx', tujuan, msg.content, msg.properties);
    ch.ack(msg);                            // ack pesan lama, salinan sudah pindah
  }
}, { noAck: false });
⛔ TTL per queue: hanya pesan di KEPALA antrean yang diperiksa

RabbitMQ mengevaluasi kedaluwarsa dari depan antrean. Bila pesan pertama punya TTL 10 menit dan pesan kedua TTL 5 detik, pesan kedua tidak akan kedaluwarsa lebih dulu — ia menunggu giliran di belakang.

Karena itu pola retry di atas memakai queue terpisah per tingkat penundaan, bukan satu queue dengan TTL berbeda per pesan. Semua pesan dalam satu queue retry punya TTL yang sama, sehingga urutannya konsisten.

DLQ Harus Bisa Ditindaklanjuti

DLQ yang tidak pernah dilihat siapa pun sama saja dengan membuang pesan, hanya dengan langkah tambahan. Yang membuatnya berguna:

  • Alert bila jumlah pesan di DLQ melebihi nol untuk jangka waktu tertentu.
  • Pemilik yang jelas — nama queue menyebut tim yang bertanggung jawab.
  • Alat pemutar ulang untuk mengembalikan pesan setelah bug diperbaiki.
  • Retensi — TTL panjang (misalnya 7 hari), agar DLQ sendiri tidak menjadi sumber alarm disk.
memutar ulang isi DLQbash
rabbitmqadmin -u admin -p rahasia \
  move_messages src_queue=pesanan.email.dlq dest_queue=pesanan.email

# atau lewat Shovel sementara, yang bisa dipantau dan dihentikan:
rabbitmqctl set_parameter shovel putar-ulang '{
  "src-protocol": "amqp091",
  "src-uri": "amqp://localhost",
  "src-queue": "pesanan.email.dlq",
  "dest-protocol": "amqp091",
  "dest-uri": "amqp://localhost",
  "dest-queue": "pesanan.email",
  "src-delete-after": "queue-length"
}'

Perhatikan src-delete-after: queue-length — shovel berhenti sendiri setelah memindahkan pesan yang ada saat ia dimulai, sehingga tidak berputar tanpa henti bila pesan gagal lagi.

Penundaan Sungguhan: Delayed Message Exchange

Untuk penundaan yang presisi dan bervariasi per pesan — misalnya "kirim pengingat 24 jam sebelum jatuh tempo" — plugin rabbitmq_delayed_message_exchange lebih tepat daripada rantai TTL.

delayed exchangejavascript
await ch.assertExchange('penjadwal', 'x-delayed-message', {
  durable: true,
  arguments: { 'x-delayed-type': 'topic' }
});

ch.publish('penjadwal', 'pengingat.jatuh-tempo', body, {
  persistent: true,
  headers: { 'x-delay': 86400000 }     // tunda 24 jam
});

Catatan penting: plugin ini menyimpan pesan tertunda di basis data internal node, bukan di queue. Ia tidak direplikasi di cluster — bila node itu hilang, pesan tertunda ikut hilang. Untuk penjadwalan jangka panjang yang kritis, tabel di basis data ditambah scheduler tetap lebih aman.