Retry Pattern #
Dalam pengembangan sistem berbasis pesan di lingkungan produksi, kita tidak boleh hanya memikirkan skenario sukses (happy path). Cara sistem kita merespon kegagalan sering kali menjadi pembeda utama antara aplikasi berskala hobi dan arsitektur kelas enterprise yang tangguh. Sepanjang section ini, kita telah mempelajari berbagai elemen penanganan kesalahan secara terpisah—mulai dari dilema requeue vs drop, fungsionalitas Dead Letter Exchange (DLX), masa hidup Message TTL, hingga algoritma Exponential Backoff untuk meredam thundering herd. Namun, di dunia nyata, elemen-elemen ini tidak boleh berdiri sendiri-sendiri secara parsial.
Untuk membangun sistem yang memiliki toleransi kesalahan tinggi (fault-tolerant), kita harus menggabungkan seluruh konsep tersebut ke dalam sebuah pola desain arsitektur yang terpadu bernama Three-Tier Retry Pattern (Pola Retry Tiga Tingkat). Pola ini bertindak sebagai cetak biru arsitektur standar industri yang memisahkan lalu lintas pesan gagal, mengatur masa tunggu secara asinkron, dan melakukan isolasi pesan rusak secara otomatis tanpa mengganggu kelancaran pemrosesan pesan sehat lainnya di dalam antrean utama kita.
Topologi Tiga Tingkat (Three-Tier Retry Topology) #
Arsitektur Three-Tier Retry Pattern membagi topologi antrean di broker RabbitMQ kita menjadi tiga jalur independen yang masing-masing memegang tanggung jawab fungsional yang sangat spesifik. Dengan memisahkan jalur ini, kita menghindari terjadinya kontaminasi antrean utama oleh pesan-pesan bermasalah.
Tiga antrean utama yang membentuk pola ini meliputi:
1. Antrean Utama (Main Queue) #
Antrean ini adalah gerbang masuk pertama untuk seluruh lalu lintas data normal yang dipublikasikan oleh produsen. Konsumen utama kita akan mendengarkan antrean ini secara aktif dalam kondisi operasional harian.
- Karakteristik: Throughput tinggi, dioptimalkan untuk latensi rendah. Antrean ini dikonfigurasi dengan Dead Letter Exchange (DLX) yang mengarah ke exchange retry, sehingga jika terjadi kegagalan pemrosesan, pesan dapat langsung dikeluarkan secepatnya dari antrean utama ini untuk membebaskan kuota prefetch.
2. Antrean Jeda Retry (Retry Delay Queue) #
Antrean ini bertindak sebagai penampung sementara untuk seluruh pesan yang gagal diproses akibat kesalahan transient (sementara).
- Karakteristik: Tidak ada aplikasi konsumen yang terhubung ke antrean ini. Antrean ini memanfaatkan properti Queue-level TTL (
x-message-ttl) sebagai pengukur waktu mundur (timer) asinkron. - Antrean ini juga dikonfigurasi dengan Dead Letter Exchange yang mengarah kembali ke Exchange Utama. Begitu masa tunggu TTL habis, broker secara otomatis akan mengalirkan kembali pesan tersebut ke antrean utama untuk dicoba lagi.
3. Antrean Kesalahan Akhir (Error DLQ) #
Antrean terminal ini merupakan tempat peristirahatan terakhir bagi pesan-pesan yang mengalami kegagalan permanen sejak awal, atau pesan transient yang telah melampaui batas maksimum percobaan ulang (Max Retry Limit) yang kita tentukan.
- Karakteristik: Antrean ini hanya diakses secara pasif. Pesan di dalamnya disimpan dengan aman tanpa batas waktu kedaluwarsa untuk kebutuhan audit manual, perbaikan bug kode, atau rekonsiliasi data transaksi secara terkontrol oleh tim operasional.
flowchart TD
Producer["Producer"] --> MainEx(("Main Exchange"))
MainEx --> MainQueue["Main Queue"]
Consumer["Konsumen Utama"] -. "Consume" .-> MainQueue
MainQueue -->|"Nack (requeue=false)"| RetryEx(("Retry Exchange"))
RetryEx --> DelayQueue["Delay Queue (TTL Jeda)"]
DelayQueue -. "Expired" .-> MainEx
Consumer -->|"Jika Retry > Max"| ErrorEx(("Error Exchange"))
ErrorEx --> ErrorDLQ["Error DLQ (Audit Manual)"]Dengan menerapkan pemisahan fisik tiga tingkat ini, antrean utama kita tetap dapat mengalirkan jutaan pesan transaksi sukses lainnya dengan kecepatan maksimal, sementara pesan yang gagal ditahan secara asinkron di antrean delay atau diisolasi dengan aman di Error DLQ.
Alur Siklus Hidup Pesan (Step-by-Step Message Lifecycle) #
Untuk memahami bagaimana data mengalir di dalam arsitektur ini, mari kita telusuri siklus hidup sebuah pesan dari awal hingga akhir pemrosesan dalam beberapa skenario:
Skenario A: Pemrosesan Normal (Happy Path) #
- Produsen menerbitkan pesan ke
main.exchangedengan routing keyorder.create. - Broker merutekan pesan ke antrean
main.queue. - Konsumen utama mengambil pesan, sukses memproses data bisnis ke database, dan mengirimkan manual
Ack. - Broker menghapus pesan secara permanen dari
main.queue.
Skenario B: Mengalami Kegagalan Transient (Retry Flow) #
- Produsen menerbitkan pesan ke
main.queue. - Konsumen utama mengambil pesan, namun koneksi database mengalami timeout saat eksekusi query.
- Konsumen mendeteksi error ini sebagai transient, membaca header counter
x-retry-countpada pesan (nilai saat ini adalah 0). - Konsumen menyalin payload pesan, menambahkan header baru
x-retry-count = 1, lalu menerbitkan pesan baru tersebut keretry.exchangedengan routing keyorder.retry. - Konsumen mengirimkan
Ackpada pesan asli dimain.queueuntuk menghapusnya dari antrean utama. - Broker menempatkan pesan baru tersebut di antrean
retry.delay.10s.queueyang memiliki TTL 10 detik. - Selama 10 detik, pesan diam di antrean delay tanpa mengganggu konsumen utama.
- Setelah 10 detik, pesan expired. Broker mengarahkan pesan tersebut melalui DLX kembali ke
main.exchange, yang kemudian merutekannya kembali kemain.queue. - Konsumen utama menerima kembali pesan tersebut untuk mencoba memprosesnya lagi dari awal.
Skenario C: Batas Retry Habis (Isolasi ke Error DLQ) #
- Setelah dicoba sebanyak 3 kali (counter
x-retry-count = 3), database ternyata masih down atau payload pesan ternyata mengandung bug logika permanen. - Konsumen mengambil pesan untuk percobaan ke-4, proses gagal kembali.
- Konsumen membaca header
x-retry-countbernilai 3. Karena batas maksimum retry kita setel sebesar 3, konsumen menyatakan pesan ini sebagai Poison Message. - Konsumen mempublikasikan pesan tersebut ke exchange isolasi
error.exchangedengan routing keyorder.error, lalu mengirimkanAckke antrean utama untuk membersihkanmain.queue. - Broker menempatkan pesan tersebut ke antrean terminal
order.error.dlq. - Aliran retry terhenti. Pesan diam di DLQ akhir dan sistem memicu alarm pemantauan agar tim developer turun tangan melakukan investigasi.
Desain Sirkuit Terbuka vs Sirkuit Tertutup (Circular Retry Mitigations) #
Salah satu kesalahan desain arsitektur yang paling sering dijumpai saat mengimplementasikan Retry Pattern adalah tidak sengaja membuat Circular Retry Loop (Aliran Retry Melingkar).
Circular retry terjadi ketika kita mendesain aliran di mana pesan gagal dikirim ke antrean delay, lalu setelah TTL habis dikembalikan ke antrean utama, dan jika gagal lagi langsung dikirim kembali ke antrean delay tanpa adanya evaluasi limit counter kegagalan. Pesan bermasalah akan terus berputar selamanya laksana lingkaran setan tanpa akhir antara antrean utama dan antrean delay. Hal ini menyebabkan penumpukan data yang tidak pernah selesai, konsumsi CPU broker yang konstan tinggi, serta kebocoran memori RAM broker.
Untuk memitigasi bahaya ini, kita wajib merancang sistem dengan Sirkuit Terbuka (Open Circuit) di tingkat aplikasi konsumen:
- Pemeriksaan Counter Wajib: Setiap kali konsumen menangkap kegagalan pemrosesan, konsumen harus memeriksa nilai counter retry (
x-retry-countatau riwayatx-death) terlebih dahulu. - Kriteria Terminasi yang Ketat: Jika counter telah melampaui ambang batas maksimum (misalnya maksimal 3 atau 5 kali), konsumen dilarang keras mengirimkan kembali pesan tersebut ke exchange retry. Pesan harus segera dievakuasi ke Exchange Error tujuan agar alur sirkular terputus.
- Pemisahan Routing Key: Bedakan routing key untuk alur normal (
order.create), alur retry (order.retry), dan alur error permanen (order.error). Jangan pernah mencampuradukkan ketiganya menggunakan wildcards*atau#yang terlalu longgar pada satu topic exchange tunggal untuk menghindari salah rute pesan.
Implementasi Kode Go (Golang) #
Berikut adalah implementasi lengkap program konsumen dalam bahasa Go yang menerapkan arsitektur Three-Tier Retry Pattern secara utuh dan aman dari bahaya circular retry loop.
package main
import (
"context"
"encoding/json"
"log"
"os"
"os/signal"
"syscall"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
// MessagePayload merepresentasikan struktur pesan transaksi utama kita
type MessagePayload struct {
ID string `json:"id"`
AccountNo string `json:"account_no"`
Amount float64 `json:"amount"`
Timestamp time.Time `json:"timestamp"`
}
func main() {
// 1. Membuka koneksi fisik ke broker RabbitMQ
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
if err != nil {
log.Fatalf("Gagal membuka koneksi ke RabbitMQ: %v", err)
}
defer conn.Close()
// 2. Membuat channel AMQP
ch, err := conn.Channel()
if err != nil {
log.Fatalf("Gagal membuka channel AMQP: %v", err)
}
defer ch.Close()
// -------------------------------------------------------------
// DEKLARASI TOPOLOGI RETRY PATTERN TIGA TINGKAT
// -------------------------------------------------------------
// KELOMPOK TIER 3: ISOLASI ERROR (ERROR DLQ)
err = ch.ExchangeDeclare("error.exchange", "direct", true, false, false, false, nil)
if err != nil {
log.Fatalf("Gagal deklarasi error.exchange: %v", err)
}
errorDlq, err := ch.QueueDeclare(
"payment.error.dlq",
true,
false,
false,
false,
amqp.Table{"x-queue-type": "quorum"},
)
if err != nil {
log.Fatalf("Gagal deklarasi payment.error.dlq: %v", err)
}
err = ch.QueueBind(errorDlq.Name, "payment.error", "error.exchange", false, nil)
if err != nil {
log.Fatalf("Gagal binding error queue: %v", err)
}
// KELOMPOK TIER 2: RETRY DELAY (RETRY QUEUE DENGAN TTL)
err = ch.ExchangeDeclare("retry.exchange", "direct", true, false, false, false, nil)
if err != nil {
log.Fatalf("Gagal deklarasi retry.exchange: %v", err)
}
// Antrean delay dikonfigurasi dengan TTL 15 detik dan DLX kembali ke main.exchange
delayQueueArgs := amqp.Table{
"x-queue-type": "quorum",
"x-message-ttl": int32(15000), // Waktu tunda: 15 detik
"x-dead-letter-exchange": "main.exchange", // Kembalikan ke exchange utama setelah expired
"x-dead-letter-routing-key": "payment.execute", // Gunakan routing key utama saat kembali
}
delayQueue, err := ch.QueueDeclare(
"payment.retry.15s.delay",
true,
false,
false,
false,
delayQueueArgs,
)
if err != nil {
log.Fatalf("Gagal deklarasi payment.retry.15s.delay: %v", err)
}
err = ch.QueueBind(delayQueue.Name, "payment.retry", "retry.exchange", false, nil)
if err != nil {
log.Fatalf("Gagal binding delay queue: %v", err)
}
// KELOMPOK TIER 1: UTAMA (MAIN QUEUE)
err = ch.ExchangeDeclare("main.exchange", "direct", true, false, false, false, nil)
if err != nil {
log.Fatalf("Gagal deklarasi main.exchange: %v", err)
}
mainQueue, err := ch.QueueDeclare(
"payment.main.queue",
true,
false,
false,
false,
amqp.Table{"x-queue-type": "quorum"},
)
if err != nil {
log.Fatalf("Gagal deklarasi payment.main.queue: %v", err)
}
err = ch.QueueBind(mainQueue.Name, "payment.execute", "main.exchange", false, nil)
if err != nil {
log.Fatalf("Gagal binding main queue: %v", err)
}
// 3. Set Prefetch QoS
err = ch.Qos(10, 0, false)
if err != nil {
log.Fatalf("Gagal menyetel Prefetch Qos: %v", err)
}
log.Println("[INFO] Topologi Three-Tier Retry sukses diaktifkan. Memulai konsumen...")
// -------------------------------------------------------------
// REGISTRASI KONSUMEN UTAMA
// -------------------------------------------------------------
msgs, err := ch.Consume(
mainQueue.Name,
"payment-main-worker",
false, // manual ACK wajib
false,
false,
false,
nil,
)
if err != nil {
log.Fatalf("Gagal registrasi konsumen: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for d := range msgs {
log.Printf("[RECEIVED] Memproses transaksi ID: %s", d.MessageId)
// Memproses data bisnis utama
err := processTransaction(d.Body)
if err != nil {
// Terjadi kegagalan pemrosesan pesan
// 1. Ekstrak nilai counter retry saat ini dari header kustom
var retryCount int32 = 0
if rawVal, ok := d.Headers["x-retry-count"]; ok {
if val, assertOk := rawVal.(int32); assertOk {
retryCount = val
}
}
maxRetryLimit := int32(3) // Batasi maksimal hanya 3 kali retry
if retryCount >= maxRetryLimit {
// KASUS A: Batas retry telah habis (Beralih ke TIER 3 - Error DLQ)
log.Printf("[POISON] Transaksi %s gagal setelah %d kali retry. Memindahkan ke Error DLQ...", d.MessageId, retryCount)
errPublish := ch.PublishWithContext(ctx,
"error.exchange", // Kirim ke exchange error permanen
"payment.error",
false,
false,
amqp.Publishing{
ContentType: d.ContentType,
MessageId: d.MessageId,
Headers: d.Headers, // Pertahankan headers riwayat
Body: d.Body,
},
)
if errPublish != nil {
log.Printf("[ERROR] Gagal mempublikasikan ke error exchange: %v", errPublish)
d.Nack(false, true) // Fallback ke antrean utama jika broker error
continue
}
} else {
// KASUS B: Masih di bawah limit (Beralih ke TIER 2 - Retry Delay)
nextRetryCount := retryCount + 1
log.Printf("[RETRY] Transaksi %s gagal. Mengirimkan ke antrean delay untuk retry ke-%d...", d.MessageId, nextRetryCount)
nextHeaders := d.Headers
if nextHeaders == nil {
nextHeaders = amqp.Table{}
}
nextHeaders["x-retry-count"] = nextRetryCount
errPublish := ch.PublishWithContext(ctx,
"retry.exchange", // Kirim ke exchange retry
"payment.retry",
false,
false,
amqp.Publishing{
ContentType: d.ContentType,
MessageId: d.MessageId,
Headers: nextHeaders, // Tulis counter baru
Body: d.Body,
},
)
if errPublish != nil {
log.Printf("[ERROR] Gagal mempublikasikan ke retry exchange: %v", errPublish)
d.Nack(false, true)
continue
}
}
// Kirimkan ACK untuk menghapus pesan asli yang gagal dari main.queue.
// Pesan baru kini telah aman dialirkan ke antrean delay atau error DLQ.
d.Ack(false)
continue
}
// Pemrosesan Sukses
log.Printf("[SUCCESS] Transaksi %s sukses terproses. Mengirimkan ACK...", d.MessageId)
d.Ack(false)
}
}()
// Menunggu sinyal shutdown OS
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Println("[INFO] Menghentikan konsumen utama secara aman...")
}
// processTransaction adalah fungsi simulasi logika bisnis database
func processTransaction(body []byte) error {
var tx MessagePayload
err := json.Unmarshal(body, &tx)
if err != nil {
return err // Mengembalikan json unmarshal error
}
return nil
}
Anti-Pattern vs Solusi Praktis #
Agar arsitektur Three-Tier Retry Pattern kita dapat beroperasi dengan optimal, hindari beberapa anti-pattern berikut:
Anti-Pattern: Menggabungkan Antrean Delay dan Final Error DLQ ke Exchange yang Sama #
Mendesain topologi di mana pesan yang tertunda waktu (delay) dan pesan kesalahan akhir (error) dikirim ke exchange yang sama dengan hanya membedakan routing key secara tipis, tanpa memisahkan antrean penampungnya secara fisik.
Mengapa ini salah? #
Pola ini sangat rentan memicu salah rute data (misrouting) jika tim developer lain secara tidak sengaja mendaftarkan binding antrean baru menggunakan karakter wildcard yang longgar (seperti payment.#).
Pesan yang seharusnya tertahan di antrean delay selama 15 menit dapat langsung mengalir ke Error DLQ tanpa sengaja, atau pesan racun yang seharusnya diisolasi di Error DLQ terputar kembali masuk ke antrean utama. Selain itu, menyatukan jalur ini mempersulit pemantauan metrik pada monitoring tool (seperti Prometheus), karena kita tidak bisa membedakan mana metrik penundaan sementara (temporary latency) dan mana metrik kegagalan fatal (fatal errors).
Solusi Praktis #
Selalu terapkan pemisahan fisik secara ketat. Gunakan exchange yang terpisah secara tegas: retry.exchange untuk alur penundaan asinkron, dan error.exchange untuk penampung kesalahan permanen. Jaga agar tidak ada satu pun jalur binding antrean delay yang tumpang tindih dengan routing key DLQ akhir.
Ringkasan #
- Konsep Three-Tier Retry — Penggabungan elemen error handling menjadi arsitektur tiga tingkat yang membagi lalu lintas pesan menjadi jalur utama (Main Queue), jalur jeda (Retry Delay Queue), dan jalur isolasi (Error DLQ).
- Fungsi Antrean Utama — Menerima data normal baru dari produsen dan melepaskan pesan gagal secepatnya melalui DLX untuk menjaga throughput tetap tinggi.
- Fungsi Antrean Delay — Menggunakan Queue-level TTL (
x-message-ttl) sebagai timer mundur asinkron dan DLX untuk mengembalikan pesan ke antrean utama, tanpa memiliki konsumen aktif terhubung.- Fungsi Error DLQ — Menyimpan pesan bermasalah atau pesan yang telah habis jatah retry-nya untuk keperluan investigasi manual oleh tim operasional tanpa batas waktu kedaluwarsa.
- Mitigasi Circular Retry — Wajib menerapkan sirkuit terbuka (open circuit) dengan membatasi jumlah percobaan retry maksimum (counter check) di tingkat konsumen guna memutus aliran melingkar yang dapat memicu crashloop broker.
- Pemisahan Fisik Ketat — Deklarasikan exchange dan antrean yang berbeda secara tegas (
retry.exchangevserror.exchange) untuk menghindari misrouting akibat wildcard binding dan mempermudah pemantauan metrik monitoring.
← Sebelumnya: Poison Message Handling Berikutnya: Queue Comparison →