queue

package
v0.53.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 11, 2026 License: MIT Imports: 14 Imported by: 3

README

queue

RabbitMQ message publishing and consuming with automatic reconnection, retry support, dead letter queues, error classification, and optional publisher confirms.

Publisher Usage

import "github.com/GunarsK-portfolio/portfolio-common/queue"

publisher, err := queue.NewRabbitMQPublisher(cfg,
    queue.WithPublisherLogger(appLogger),       // optional, for reconnect logs
    queue.WithPublisherMetrics(queueMetrics),   // optional Prometheus metrics
)
if err != nil {
    log.Fatal(err)
}
defer publisher.Close()

// Publish message (CorrelationId is taken from ctx when present, see Tracing)
err = publisher.Publish(ctx, message)

// Retry failed message (with headers for retry tracking)
err = publisher.PublishToRetry(ctx, retryIndex, body, correlationID, headers)

// Send to dead letter queue
err = publisher.PublishToDLQ(ctx, body, correlationID)

Consumer Usage

import "github.com/GunarsK-portfolio/portfolio-common/queue"

consumer, err := queue.NewRabbitMQConsumer(cfg, publisher, logger,
    queue.WithConsumerMetrics(queueMetrics),    // optional Prometheus metrics
)
if err != nil {
    log.Fatal(err)
}
defer consumer.Close()

// Consume messages (blocks until context cancelled or Close is called)
err = consumer.Consume(ctx, func(c context.Context, d amqp.Delivery) error {
    // Return nil to ACK.
    // Return an error to trigger the retry ladder.
    // Return queue.Permanent(err) to skip retries and go straight to the DLQ.
    return nil
})

// Get retry count from message headers
retryCount := queue.GetRetryCount(delivery)

// Inside a handler: will this delivery retry on a transient error, or is
// this the final attempt before the DLQ? Uses the same predicate the
// consumer applies after the handler returns.
finalAttempt := !queue.WillRetry(delivery, publisher.MaxRetries())

Features

  • Automatic exchange and queue declaration
  • Automatic reconnection with exponential backoff (publisher and consumer)
  • Configurable retry delays with TTL-based routing and optional jitter
  • Dead letter queue for permanent failures
  • Permanent error classification (queue.Permanent / queue.ErrPermanent)
  • Optional publisher confirms
  • Optional concurrent message processing
  • Correlation ID propagation between HTTP requests and queue messages
  • Optional Prometheus metrics (see the metrics package)
  • Thread-safe publishing and consuming

Delivery Semantics

Delivery is at-least-once. Handlers must be idempotent. Duplicates can occur when:

  • A handler succeeds but the ACK fails (broker redelivers).
  • A message is republished to a retry queue but the ACK of the original fails (both copies eventually arrive).
  • The consumer reconnects while messages were in flight.

The redelivered flag and MessageId on the delivery can support deduplication where needed.

Retry Flow

  1. Message fails processing with an ordinary error: the consumer republishes it to the next retry queue with an incremented x-retry-count header and ACKs the original.
  2. After the queue TTL expires, the message returns to the main queue.
  3. When retryCount >= MaxRetries(), the message is NACKed and routes to the DLQ.
  4. Errors matching queue.ErrPermanent skip the ladder entirely and route straight to the DLQ.
  5. If the consume context is cancelled while a message is being handled (shutdown), the message is requeued without consuming a retry attempt.

Handlers that need to record the upcoming routing decision (e.g. "will retry" vs "final attempt") should use queue.WillRetry(delivery, publisher.MaxRetries()) instead of re-deriving it from the retry count - it is the same predicate the consumer applies, so the two cannot drift. A handler returning a queue.Permanent error always goes to the DLQ regardless of what WillRetry reported.

Panics

A handler panic does not crash the worker: the consumer recovers it, logs the stack at error level, and treats it as a transient handler error that rides the retry ladder. A deterministically panicking message therefore reaches the DLQ after the configured retries instead of crash-looping the process. Mark input-dependent failures with queue.Permanent in the handler when retrying is pointless.

Retry Jitter

With RetryJitter set (0 to 1), each retry message gets a per-message TTL randomly shortened by up to that fraction (e.g. jitter 0.2 on a 5m delay yields TTLs between 4m and 5m). This spreads out retries of messages that failed together. The queue-level TTL stays the upper bound, so existing topologies are unaffected. Note RabbitMQ only expires messages at the queue head, so under bursts a message may wait for messages ahead of it.

Reconnection

Both publisher and consumer automatically re-establish dropped connections and channels with exponential backoff (defaults: 1s initial, 30s cap, plus jitter, unlimited attempts), re-declaring topology on success. Disable with DisableReconnect / RABBITMQ_RECONNECT=false to restore the old fail-fast behavior.

  • Publishes issued while disconnected fail fast with ErrPublishFailed; callers decide whether to retry.
  • Consume resumes delivery after reconnecting and then only returns on context cancellation, Close(), or when ReconnectMaxAttempts is exceeded (ErrReconnectFailed).
  • The initial connection in the constructors remains fail-fast so misconfiguration is caught at startup. Wrap the constructor in a retry loop if you need patience at boot.
  • AMQP heartbeats default to 10s (RABBITMQ_HEARTBEAT) so dead peers are detected even on quiet connections.

Publisher Confirms

With PublisherConfirms enabled, Publish blocks until the broker confirms the message was received (bounded by ctx) and returns ErrPublishNotConfirmed on a broker NACK. This closes the window where a broker crash between channel-accept and persist silently loses a message, at the cost of one broker round-trip per publish. Off by default.

Concurrency and Prefetch

Consume processes messages sequentially by default. Set ConsumerConcurrency (and a matching PrefetchCount) to process N messages in parallel; effective parallelism is min(ConsumerConcurrency, PrefetchCount).

For long-running handlers keep PrefetchCount equal to ConsumerConcurrency: prefetched messages sit unacknowledged on this consumer and are invisible to others until processed.

Graceful Shutdown

  • Cancelling the consume context stops fetching; in-flight handlers run to completion (handlers should honor ctx for long work) and their messages are requeued without burning a retry attempt if they fail.
  • Close() on the consumer blocks until in-flight handlers finish and the connection is released. Never call it from inside a handler (it would deadlock waiting for that handler); cancel the consume context instead.
  • Shut down the consumer before the publisher: in-flight handlers may still publish to retry queues.

Tracing

Publish uses the correlation ID from the context (logger.GetCorrelationID) as the message CorrelationId when present, so messages link back to the originating HTTP request. The consumer puts the delivery's correlation ID back into the handler context, so handlers using logger.FromContext log it automatically. Retries preserve it.

Connection Ownership

Both publisher and consumer own their own RabbitMQ connections. Connection() returns the current connection for read-only purposes; the pointer changes after a reconnect, so health checks must use the provider form:

healthAgg.Register(health.NewRabbitMQCheckerWithProvider(publisher.Connection))

Do not call conn.Close() directly - use publisher.Close() or consumer.Close() instead.

DLQ Monitoring

Expose the DLQ depth on the health endpoint (and optionally as a Prometheus gauge via metrics.QueueMetrics.SetQueueDepth):

healthAgg.Register(
    health.NewQueueDepthChecker(publisher.Connection, publisher.DLQName(), 0))

Configuration

cfg := config.RabbitMQConfig{
    Host:        "localhost",
    Port:        5672,
    User:        "guest",
    Password:    "guest",
    TLS:         false,            // Set to true for amqps:// (Amazon MQ, production)
    Exchange:    "messaging",
    Queue:       "contact_messages",
    RetryDelays: []time.Duration{1*time.Minute, 5*time.Minute, 30*time.Minute},
    RetryJitter: 0.2,              // optional, 0 disables

    PublisherConfirms: false,      // optional broker-confirmed publishes

    // Reconnection (zero values mean: enabled, unlimited, 1s initial, 30s cap)
    DisableReconnect:      false,
    ReconnectMaxAttempts:  0,
    ReconnectInitialDelay: time.Second,
    ReconnectMaxDelay:     30 * time.Second,

    // Consumer-specific (optional)
    PrefetchCount:       1,             // QoS prefetch count
    ConsumerTag:         "my-consumer", // Unique consumer identifier
    ConsumerConcurrency: 1,             // parallel handlers
}

Environment Variables

Variable Required Default Description
RABBITMQ_HOST Yes - RabbitMQ hostname
RABBITMQ_PORT Yes - RabbitMQ port
RABBITMQ_USER Yes - Username
RABBITMQ_PASSWORD Yes - Password
RABBITMQ_TLS No false Use TLS (amqps://) connection
RABBITMQ_EXCHANGE No contact_messages Exchange name
RABBITMQ_QUEUE No contact_messages Queue name
RABBITMQ_RETRY_DELAYS No 1m,5m,30m,2h,12h Comma-separated durations
RABBITMQ_RETRY_JITTER No 0 Retry delay jitter fraction (0-1)
RABBITMQ_HEARTBEAT No 10s AMQP heartbeat interval
RABBITMQ_PUBLISHER_CONFIRMS No false Enable publisher confirms
RABBITMQ_RECONNECT No true Automatic reconnection
RABBITMQ_RECONNECT_MAX_ATTEMPTS No 0 Reconnect attempt limit (0 = unlimited)
RABBITMQ_RECONNECT_INITIAL_DELAY No 1s First reconnect backoff delay
RABBITMQ_RECONNECT_MAX_DELAY No 30s Reconnect backoff cap
RABBITMQ_PREFETCH_COUNT No 1 Consumer QoS prefetch
RABBITMQ_CONSUMER_TAG No "" Consumer identifier
RABBITMQ_CONSUMER_CONCURRENCY No 1 Parallel message handlers
Multiple Queues per Service

config.NewRabbitMQConfigWithPrefix("AI_") reads AI_RABBITMQ_* variables, falling back to the un-prefixed names, so two queues with different retry ladders can coexist in one service:

RABBITMQ_HOST=...                  # shared
RABBITMQ_USER=...                  # shared
RABBITMQ_QUEUE=contact_messages    # queue 1
AI_RABBITMQ_QUEUE=ai_jobs          # queue 2
AI_RABBITMQ_RETRY_DELAYS=5s,15s,1m # queue 2 retry ladder

Queue Infrastructure

Created automatically by NewRabbitMQPublisher (and re-declared on every reconnect and consumer setup):

  • Main queue (contact_messages) - Primary message queue
  • Retry queues (contact_messages_retry_0, _1, etc.) - TTL-based delays
  • DLQ (contact_messages_dlq) - Permanent failures
  • Exchanges - Direct exchanges for routing

All queues and exchanges are durable and messages are published persistent.

Changing RETRY_DELAYS against an existing topology: RabbitMQ rejects re-declaring a queue with different arguments (PRECONDITION_FAILED). Delete the old retry queues (or use a new queue name) before changing the delay ladder of a deployed queue.

Testing

Integration tests in integration_test.go cover publish/consume, the retry ladder, DLQ routing, permanent errors, reconnection, confirms, shutdown semantics, and concurrency against a real RabbitMQ in Docker (testcontainers-go). They are skipped with -short or when Docker is unavailable.

Documentation

Index

Constants

View Source
const (
	OutcomeSuccess  = "success"  // handler succeeded, message ACKed
	OutcomeRetry    = "retry"    // handler failed, message sent to a retry queue
	OutcomeDLQ      = "dlq"      // message sent to the DLQ (retries exhausted or permanent error)
	OutcomeRequeued = "requeued" // message requeued without consuming a retry attempt (shutdown)
)

Consume outcomes reported to MetricsRecorder.RecordConsume.

View Source
const RetryCountHeader = "x-retry-count"

RetryCountHeader is the AMQP header key for tracking retry attempts

Variables

View Source
var (
	ErrConsumerClosed        = errors.New("consumer is closed")
	ErrConsumeSetupFailed    = errors.New("failed to setup consumer")
	ErrNilPublisher          = errors.New("publisher is required")
	ErrAlreadyConsuming      = errors.New("consumer is already consuming")
	ErrDeliveryChannelClosed = errors.New("delivery channel closed")
	ErrReconnectFailed       = errors.New("reconnect attempts exhausted")
)

Consumer errors

View Source
var (
	ErrConnectionFailed    = errors.New("failed to connect to RabbitMQ")
	ErrChannelFailed       = errors.New("failed to open channel")
	ErrQueueSetupFailed    = errors.New("failed to setup queue infrastructure")
	ErrMarshalFailed       = errors.New("failed to marshal message")
	ErrPublishFailed       = errors.New("failed to publish message")
	ErrPublishNotConfirmed = errors.New("publish not confirmed by broker")
	ErrPublisherClosed     = errors.New("publisher is closed")
	ErrRetryOutOfBounds    = errors.New("retry index out of bounds")
	ErrCloseFailed         = errors.New("failed to close connection")
)

Common errors returned by the queue package.

View Source
var ErrPermanent = errors.New("permanent failure")

ErrPermanent marks a handler error as permanent: the message can never be processed successfully (e.g. malformed payload, missing referenced entity). The consumer sends such messages straight to the DLQ instead of burning retry attempts.

Handlers can either wrap an error with Permanent():

return queue.Permanent(fmt.Errorf("unmarshal event: %w", err))

or wrap the sentinel directly:

return fmt.Errorf("unknown message type %q: %w", t, queue.ErrPermanent)

Both forms satisfy errors.Is(err, queue.ErrPermanent).

Functions

func GetRetryCount added in v0.34.0

func GetRetryCount(delivery amqp.Delivery) int

GetRetryCount extracts the retry count from message headers. Unknown types and negative values are treated as 0.

func Permanent added in v0.51.0

func Permanent(err error) error

Permanent wraps err so the consumer routes the message straight to the DLQ. Returns nil if err is nil. The wrapped error is available via errors.Unwrap.

func WillRetry added in v0.52.0

func WillRetry(delivery amqp.Delivery, maxRetries int) bool

WillRetry reports whether a delivery that fails with a transient error will be routed to a retry queue (true) or dead-lettered (false). It is the exact predicate the consumer applies after the handler returns, so handlers can record the upcoming routing decision without re-deriving it. maxRetries is the number of configured retry queues (publisher.MaxRetries()). Errors matching ErrPermanent go straight to the DLQ regardless.

Types

type Consumer added in v0.34.0

type Consumer interface {
	// Consume starts consuming messages and blocks until context is cancelled
	Consume(ctx context.Context, handler MessageHandler) error
	// Close stops consuming and closes connections
	Close() error
}

Consumer defines the interface for message queue consuming

type ConsumerOption added in v0.51.0

type ConsumerOption func(*RabbitMQConsumer)

ConsumerOption configures a RabbitMQConsumer.

func WithConsumerMetrics added in v0.51.0

func WithConsumerMetrics(m MetricsRecorder) ConsumerOption

WithConsumerMetrics sets the metrics recorder for consume and reconnect events. Defaults to a no-op recorder.

type MessageHandler added in v0.34.0

type MessageHandler func(ctx context.Context, delivery amqp.Delivery) error

MessageHandler processes a single message delivery. Return nil to ACK the message, return error to trigger retry logic. Wrap errors with Permanent() to skip retries and go straight to the DLQ. The context carries the message correlation ID (logger.GetCorrelationID).

A panic inside the handler does not crash the process: it is recovered, logged with the stack, and treated as a transient handler error that rides the retry ladder (reaching the DLQ once retries are exhausted).

type MetricsRecorder added in v0.51.0

type MetricsRecorder interface {
	// RecordPublish is called after every publish attempt. queue is the
	// routing target (main queue, retry queue, or DLQ name).
	RecordPublish(queue string, success bool, duration time.Duration)
	// RecordConsume is called after a delivery is processed. outcome is one
	// of the Outcome* constants; duration is the handler execution time.
	RecordConsume(queue string, outcome string, duration time.Duration)
	// RecordReconnect is called when a dropped connection is detected and a
	// reconnect cycle starts. component is "publisher" or "consumer".
	RecordReconnect(component string)
}

MetricsRecorder receives queue events for instrumentation. Implementations must be safe for concurrent use and must not panic - recorder calls run on the publish and consume paths and, unlike message handlers, are not recovered. The metrics package provides a Prometheus implementation (metrics.QueueMetrics); pass it via WithPublisherMetrics / WithConsumerMetrics. When no recorder is configured, events are discarded.

type Publisher

type Publisher interface {
	Publish(ctx context.Context, message interface{}) error
	PublishToRetry(ctx context.Context, retryIndex int, body []byte, correlationId string, headers amqp.Table) error
	PublishToDLQ(ctx context.Context, body []byte, correlationId string) error
	MaxRetries() int
	Close() error
}

Publisher defines the interface for message queue publishing with retry support

type PublisherOption added in v0.51.0

type PublisherOption func(*RabbitMQPublisher)

PublisherOption configures a RabbitMQPublisher.

func WithPublisherLogger added in v0.51.0

func WithPublisherLogger(logger *slog.Logger) PublisherOption

WithPublisherLogger sets the logger used for reconnection events. Defaults to slog.Default().

func WithPublisherMetrics added in v0.51.0

func WithPublisherMetrics(m MetricsRecorder) PublisherOption

WithPublisherMetrics sets the metrics recorder for publish and reconnect events. Defaults to a no-op recorder.

type RabbitMQConsumer added in v0.34.0

type RabbitMQConsumer struct {
	// contains filtered or unexported fields
}

RabbitMQConsumer implements Consumer for RabbitMQ

func NewRabbitMQConsumer added in v0.34.0

func NewRabbitMQConsumer(
	cfg config.RabbitMQConfig,
	publisher *RabbitMQPublisher,
	logger *slog.Logger,
	opts ...ConsumerOption,
) (*RabbitMQConsumer, error)

NewRabbitMQConsumer creates a new consumer that shares queue infrastructure with the publisher. The publisher must be created first as it declares all queues. If logger is nil, slog.Default() is used.

func (*RabbitMQConsumer) Close added in v0.34.0

func (c *RabbitMQConsumer) Close() error

Close stops consuming and closes the connection. If a Consume call is active, Close blocks until in-flight handlers finish and Consume returns. Idempotent: subsequent calls return nil.

Close must not be called from inside a MessageHandler: it waits for that very handler to finish and would deadlock. To stop consuming from within a handler, cancel the context passed to Consume instead.

func (*RabbitMQConsumer) Connection added in v0.34.0

func (c *RabbitMQConsumer) Connection() *amqp.Connection

Connection returns the current AMQP connection for health checks. The returned pointer changes after a reconnect; health checks should use health.NewRabbitMQCheckerWithProvider(consumer.Connection) so they always observe the current connection.

func (*RabbitMQConsumer) Consume added in v0.34.0

func (c *RabbitMQConsumer) Consume(ctx context.Context, handler MessageHandler) error

Consume starts consuming messages from the queue and blocks until the context is cancelled or Close is called.

Unlike the publisher (whose supervisor goroutine reconnects in the background), reconnection runs inline in this loop because in-flight handlers must be drained before the channel they ack on is replaced.

Unless cfg.DisableReconnect is set, a dropped connection or channel is re-established with exponential backoff and consumption resumes; Consume then only returns ctx.Err() on cancellation, ErrConsumerClosed after Close, or ErrReconnectFailed when cfg.ReconnectMaxAttempts is exceeded. With reconnection disabled, it returns ErrDeliveryChannelClosed when the broker connection drops (the previous behavior).

Messages are processed with cfg.ConsumerConcurrency parallel handlers (default 1, sequential). Consume waits for in-flight handlers to finish before returning. Only one Consume may be active per consumer; concurrent calls return ErrAlreadyConsuming.

The handler is called for each message; return nil to ACK, an error to trigger the retry ladder, or a Permanent()-wrapped error to send the message straight to the DLQ. If the context is cancelled while a message is being handled, the message is requeued without consuming a retry attempt.

type RabbitMQPublisher

type RabbitMQPublisher struct {
	// contains filtered or unexported fields
}

RabbitMQPublisher implements Publisher for RabbitMQ. All publish methods are safe for concurrent use.

func NewRabbitMQPublisher

func NewRabbitMQPublisher(cfg config.RabbitMQConfig, opts ...PublisherOption) (*RabbitMQPublisher, error)

NewRabbitMQPublisher creates a new RabbitMQ publisher with exchange, retry queues, and DLQ.

The publisher is safe for concurrent use from multiple goroutines.

Unless cfg.DisableReconnect is set, the publisher automatically reconnects with exponential backoff when the connection or channel drops, re-declaring the topology on success. Publishes issued while disconnected fail fast with ErrPublishFailed; callers decide whether to retry. The initial connection is still fail-fast: if RabbitMQ is unreachable at startup an error is returned.

With cfg.PublisherConfirms enabled, every publish blocks until the broker confirms the message (bounded by ctx) and returns ErrPublishNotConfirmed on a broker NACK.

Retry flow: Consumers must explicitly call PublishToRetry() to route failed messages through the retry chain. The main queue's dead-letter config routes directly to DLQ for unhandled failures (e.g., message rejected without calling PublishToRetry).

If cfg.RetryDelays is empty, rejected messages route directly to the DLQ with no retry attempts.

func (*RabbitMQPublisher) Close

func (p *RabbitMQPublisher) Close() error

Close closes the channel and connection and stops the reconnect supervisor. Safe to call concurrently with Publish methods - will wait for in-flight publishes to complete. Idempotent: subsequent calls return nil.

func (*RabbitMQPublisher) Connection added in v0.33.0

func (p *RabbitMQPublisher) Connection() *amqp.Connection

Connection returns the current AMQP connection for health checks. The returned pointer changes after a reconnect; health checks should use health.NewRabbitMQCheckerWithProvider(publisher.Connection) so they always observe the current connection.

func (*RabbitMQPublisher) DLQName

func (p *RabbitMQPublisher) DLQName() string

DLQName returns the dead letter queue name

func (*RabbitMQPublisher) DLXName

func (p *RabbitMQPublisher) DLXName() string

DLXName returns the dead letter exchange name

func (*RabbitMQPublisher) MaxRetries

func (p *RabbitMQPublisher) MaxRetries() int

MaxRetries returns the number of retry queues (attempts before DLQ)

func (*RabbitMQPublisher) Publish

func (p *RabbitMQPublisher) Publish(ctx context.Context, message interface{}) error

Publish sends a message to the main queue. The message CorrelationId is taken from the context (logger.GetCorrelationID) when present so messages can be traced back to the originating request; otherwise a new one is generated. It is preserved through retries.

func (*RabbitMQPublisher) PublishToDLQ

func (p *RabbitMQPublisher) PublishToDLQ(ctx context.Context, body []byte, correlationId string) error

PublishToDLQ sends a message to the dead letter queue (permanent failure). The correlationId should be preserved from the original message for tracing.

func (*RabbitMQPublisher) PublishToRetry

func (p *RabbitMQPublisher) PublishToRetry(ctx context.Context, retryIndex int, body []byte, correlationId string, headers amqp.Table) error

PublishToRetry sends a message to a specific retry queue by index. Returns error if retryIndex is out of bounds (should send to DLQ instead). The correlationId should be preserved from the original message for tracing. Headers is optional (can be nil) - used to track retry count.

When cfg.RetryJitter > 0, a per-message TTL shortened by up to that fraction is set so messages that failed together do not all return to the main queue at the same instant. The queue-level TTL remains the upper bound; per-message expiry only shortens the delay (RabbitMQ uses the lower of the two). Because RabbitMQ expires only the queue head, a message may still wait for messages ahead of it.

func (*RabbitMQPublisher) RetryQueues

func (p *RabbitMQPublisher) RetryQueues() []string

RetryQueues returns a copy of the retry queue names for use by consumers

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL