Retry Pattern #
In developing message-based systems in production, we can’t only think about the happy path. How our system responds to failures is often the main differentiator between hobby-scale applications and resilient enterprise-class architectures. Throughout this section, we’ve studied various error-handling elements separately—from the requeue vs drop dilemma, Dead Letter Exchange (DLX) functionality, Message TTL lifetimes, to the Exponential Backoff algorithm for dampening thundering herds. However, in the real world, these elements shouldn’t stand alone partially.
To build a system with high fault tolerance, we must combine all those concepts into a unified architecture design pattern called the Three-Tier Retry Pattern. This pattern acts as an industry-standard architecture blueprint separating failed message traffic, managing wait times asynchronously, and automatically isolating corrupted messages without disrupting the smooth processing of other healthy messages in our main queue.
Three-Tier Retry Topology #
The Three-Tier Retry Pattern architecture divides the queue topology on our RabbitMQ broker into three independent paths, each holding very specific functional responsibilities. By separating these paths, we avoid contaminating the main queue with problematic messages.
The three main queues forming this pattern include:
1. Main Queue #
This queue is the first entry gate for all normal data traffic published by producers. Our main consumers actively listen to this queue under daily operational conditions.
- Characteristics: High throughput, optimized for low latency. This queue is configured with a Dead Letter Exchange (DLX) pointing to the retry exchange, so if processing failures occur, messages can be immediately removed from this main queue as quickly as possible to free up prefetch quotas.
2. Retry Delay Queue #
This queue acts as a temporary holding area for all messages that fail to process due to transient (temporary) errors.
- Characteristics: No consumer applications are connected to this queue. This queue leverages the Queue-level TTL property (
x-message-ttl) as an asynchronous timer countdown. - This queue is also configured with a Dead Letter Exchange pointing back to the Main Exchange. Once the TTL wait period elapses, the broker automatically flows the message back to the main queue to be retried.
3. Error DLQ #
This terminal queue is the final resting place for messages experiencing permanent failures from the start, or transient messages that have exceeded the Max Retry Limit we set.
- Characteristics: This queue is only accessed passively. Messages inside are safely stored without expiration time limits for manual audit needs, code bug fixes, or controlled transaction data reconciliation by operations teams.
flowchart TD
Producer["Producer"] --> MainEx(("Main Exchange"))
MainEx --> MainQueue["Main Queue"]
Consumer["Main Consumer"] -. "Consume" .-> MainQueue
MainQueue -->|"Nack (requeue=false)"| RetryEx(("Retry Exchange"))
RetryEx --> DelayQueue["Delay Queue (TTL Delay)"]
DelayQueue -. "Expired" .-> MainEx
Consumer -->|"If Retry > Max"| ErrorEx(("Error Exchange"))
ErrorEx --> ErrorDLQ["Error DLQ (Manual Audit)"]By applying this three-tier physical separation, our main queue keeps flowing millions of other successful transaction messages at maximum speed, while failed messages are held asynchronously in the delay queue or safely isolated in the Error DLQ.
Step-by-Step Message Lifecycle #
To understand how data flows inside this architecture, let’s trace a message’s lifecycle from start to finish of processing in several scenarios:
Scenario A: Normal Processing (Happy Path) #
- A producer publishes a message to
main.exchangewith theorder.createrouting key. - The broker routes the message to the
main.queue. - The main consumer takes the message, successfully processes the business data into the database, and sends a manual
Ack. - The broker permanently deletes the message from
main.queue.
Scenario B: Experiencing a Transient Failure (Retry Flow) #
- A producer publishes a message to
main.queue. - The main consumer takes the message, but the database connection times out during query execution.
- The consumer detects this error as transient, reads the
x-retry-countcounter header on the message (the current value is 0). - The consumer copies the message payload, adds a new
x-retry-count = 1header, then publishes that new message toretry.exchangewith theorder.retryrouting key. - The consumer sends an
Ackon the original message inmain.queueto delete it from the main queue. - The broker places that new message in the
retry.delay.10s.queuewith a 10-second TTL. - For 10 seconds, the message stays in the delay queue without disturbing the main consumer.
- After 10 seconds, the message expires. The broker routes that message through the DLX back to
main.exchange, which then routes it back tomain.queue. - The main consumer receives that message again to try processing it from scratch.
Scenario C: Retry Limit Exhausted (Isolation to the Error DLQ) #
- After being tried 3 times (counter
x-retry-count = 3), the database is still down or the message payload turns out to contain a permanent logic bug. - The consumer takes the message for the 4th attempt; the process fails again.
- The consumer reads the
x-retry-countheader with value 3. Because our maximum retry limit is set to 3, the consumer declares this message a Poison Message. - The consumer publishes that message to the isolation exchange
error.exchangewith theorder.errorrouting key, then sends anAckto the main queue to cleanmain.queue. - The broker places that message into the terminal queue
order.error.dlq. - The retry flow stops. The message rests in the final DLQ and the system triggers monitoring alarms so developer teams intervene for investigation.
Open Circuit vs Closed Circuit Design (Circular Retry Mitigations) #
One of the most common architecture design errors when implementing Retry Patterns is unintentionally creating a Circular Retry Loop.
Circular retry occurs when we design a flow where failed messages go to the delay queue, then after the TTL expires return to the main queue, and if they fail again are immediately sent back to the delay queue without any failure counter limit evaluation. The problematic message keeps spinning forever like a vicious cycle between the main queue and delay queue. This causes never-ending data pile-ups, constantly high broker CPU consumption, and broker RAM memory leaks.
To mitigate this danger, we must design the system with an Open Circuit at the consumer application level:
- Mandatory Counter Checks: Every time a consumer catches a processing failure, the consumer must check the retry counter value (
x-retry-countor thex-deathhistory) first. - Strict Termination Criteria: If the counter has exceeded the maximum threshold (e.g., a maximum of 3 or 5 times), the consumer is strictly forbidden from sending the message back to the retry exchange. The message must be immediately evacuated to the destination Error Exchange to break the circular flow.
- Routing Key Separation: Differentiate routing keys for the normal flow (
order.create), retry flow (order.retry), and permanent error flow (order.error). Never mix all three using overly loose*or#wildcards on a single topic exchange to avoid message misrouting.
Go Code Implementation (Golang) #
Here is a complete Go consumer program implementing the Three-Tier Retry Pattern architecture fully and safely from circular retry loop dangers.
package main
import (
"context"
"encoding/json"
"log"
"os"
"os/signal"
"syscall"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
// MessagePayload represents our main transaction message structure
type MessagePayload struct {
ID string `json:"id"`
AccountNo string `json:"account_no"`
Amount float64 `json:"amount"`
Timestamp time.Time `json:"timestamp"`
}
func main() {
// 1. Open the physical connection to the RabbitMQ broker
conn, err := amqp.Dial("amqp://guest:***@localhost:5672/")
if err != nil {
log.Fatalf("Gagal membuka koneksi ke RabbitMQ: %v", err)
}
defer conn.Close()
// 2. Create an AMQP channel
ch, err := conn.Channel()
if err != nil {
log.Fatalf("Gagal membuka channel AMQP: %v", err)
}
defer ch.Close()
// -------------------------------------------------------------
// THREE-TIER RETRY PATTERN TOPOLOGY DECLARATION
// -------------------------------------------------------------
// TIER 3 GROUP: ERROR ISOLATION (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)
}
// TIER 2 GROUP: RETRY DELAY (RETRY QUEUE WITH TTL)
err = ch.ExchangeDeclare("retry.exchange", "direct", true, false, false, false, nil)
if err != nil {
log.Fatalf("Gagal deklarasi retry.exchange: %v", err)
}
// The delay queue is configured with a 15-second TTL and a DLX back to main.exchange
delayQueueArgs := amqp.Table{
"x-queue-type": "quorum",
"x-message-ttl": int32(15000), // Delay time: 15 seconds
"x-dead-letter-exchange": "main.exchange", // Return to the main exchange after expiry
"x-dead-letter-routing-key": "payment.execute", // Use the main routing key when returning
}
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)
}
// TIER 1 GROUP: MAIN (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 the 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...")
// -------------------------------------------------------------
// MAIN CONSUMER REGISTRATION
// -------------------------------------------------------------
msgs, err := ch.Consume(
mainQueue.Name,
"payment-main-worker",
false, // manual ACK required
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)
// Process the main business data
err := processTransaction(d.Body)
if err != nil {
// A message processing failure occurred
// 1. Extract the current retry counter value from the custom header
var retryCount int32 = 0
if rawVal, ok := d.Headers["x-retry-count"]; ok {
if val, assertOk := rawVal.(int32); assertOk {
retryCount = val
}
}
maxRetryLimit := int32(3) // Limit to a maximum of only 3 retries
if retryCount >= maxRetryLimit {
// CASE A: The retry limit is exhausted (switch to 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", // Send to the permanent error exchange
"payment.error",
false,
false,
amqp.Publishing{
ContentType: d.ContentType,
MessageId: d.MessageId,
Headers: d.Headers, // Keep the history headers
Body: d.Body,
},
)
if errPublish != nil {
log.Printf("[ERROR] Gagal mempublikasikan ke error exchange: %v", errPublish)
d.Nack(false, true) // Fallback to the main queue if the broker errors
continue
}
} else {
// CASE B: Still under the limit (switch to 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", // Send to the retry exchange
"payment.retry",
false,
false,
amqp.Publishing{
ContentType: d.ContentType,
MessageId: d.MessageId,
Headers: nextHeaders, // Write the new counter
Body: d.Body,
},
)
if errPublish != nil {
log.Printf("[ERROR] Gagal mempublikasikan ke retry exchange: %v", errPublish)
d.Nack(false, true)
continue
}
}
// Send an ACK to delete the original failed message from main.queue.
// The new message is now safely flowed to the delay queue or error DLQ.
d.Ack(false)
continue
}
// Successful processing
log.Printf("[SUCCESS] Transaksi %s sukses terproses. Mengirimkan ACK...", d.MessageId)
d.Ack(false)
}
}()
// Wait for the OS shutdown signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Println("[INFO] Menghentikan konsumen utama secara aman...")
}
// processTransaction is a simulated database business logic function
func processTransaction(body []byte) error {
var tx MessagePayload
err := json.Unmarshal(body, &tx)
if err != nil {
return err // Returns a json unmarshal error
}
return nil
}
Anti-Patterns vs Practical Solutions #
To make our Three-Tier Retry Pattern architecture operate optimally, avoid the following anti-patterns:
Anti-Pattern: Combining the Delay Queue and Final Error DLQ into the Same Exchange #
Designing a topology where delayed (time-waiting) messages and final error messages are sent to the same exchange, only differentiated by thin routing keys, without physically separating their holding queues.
Why is this wrong? #
This pattern is highly vulnerable to misrouting if other developer teams accidentally register new queue bindings using loose wildcard characters (like payment.#).
Messages that should be held in the delay queue for 15 minutes can accidentally flow directly to the Error DLQ, or poison messages that should be isolated in the Error DLQ loop back into the main queue. Additionally, merging these paths makes metric monitoring harder on monitoring tools (like Prometheus), because we can’t distinguish temporary latency metrics from fatal error metrics.
Practical Solution #
Always apply strict physical separation. Use clearly separate exchanges: retry.exchange for asynchronous delay flows, and error.exchange for permanent error holding. Ensure no delay queue binding path overlaps with the final DLQ routing keys.
Summary #
- The Three-Tier Retry Concept — Combining error-handling elements into a three-tier architecture dividing message traffic into a main path (Main Queue), delay path (Retry Delay Queue), and isolation path (Error DLQ).
- Main Queue Function — Receives new normal data from producers and releases failed messages as quickly as possible through the DLX to keep throughput high.
- Delay Queue Function — Uses Queue-level TTL (
x-message-ttl) as an asynchronous countdown timer and DLX to return messages to the main queue, without any active consumers connected.- Error DLQ Function — Stores problematic messages or messages that exhausted their retry allocation for manual investigation by operations teams without expiration time limits.
- Circular Retry Mitigation — Must implement an open circuit by limiting the maximum retry count (counter checks) at the consumer level to break circular flows that can trigger broker crash loops.
- Strict Physical Separation — Declare clearly different exchanges and queues (
retry.exchangevserror.exchange) to avoid misrouting from wildcard bindings and make monitoring metric tracking easier.
← Previous: Poison Message Handling Next: Queue Comparison →