Guide

Retries & dead letters

Failures retry with exponential backoff, then route to a dead-letter queue instead of spinning forever.

Retries ride the visibility timeout#

pgbus doesn't re-enqueue a failed job. When a job raises, the worker lets the PGMQ visibility timeout expire — the message simply becomes visible again and another worker picks it up. PGMQ's read_ct counts each delivery, so pgbus always knows how many attempts a message has had.

This is why there's no separate retry table: the queue itself is the retry mechanism. A message that fails is never lost — it's just invisible until its timeout lapses.

Exponential backoff with jitter#

Spread retries out instead of bunching them.

Rather than retry at a fixed interval, pgbus extends the visibility timeout with exponential backoff and a little jitter, so a thundering herd of failures doesn't all retry at the same instant:

config/initializers/pgbus.rb
Pgbus.configure do |config|
  config.retry_backoff        = 5    # base delay (seconds)
  config.retry_backoff_max    = 300  # cap at 5 minutes
  config.retry_backoff_jitter = 0.15 # ±15% randomization
end

The delay is base * 2^(attempt-1) * (1 + jitter). With the defaults, a job that fails four times waits roughly 5s, 10s, 20s, 40s before it hits the DLQ on the fifth read.

Per-job overrides#

A fragile job — say one calling a flaky third-party API — can widen its own backoff without changing the global setting:

app/jobs/fragile_api_job.rb
class FragileApiJob < ApplicationJob
  include Pgbus::RetryBackoff::JobMixin

  pgbus_retry_backoff base: 10, max: 600, jitter: 0.2

  def perform(...)
    # ...
  end
end

The dead-letter queue#

read_ct > max_retries → <queue>_dlq.

When read_ct exceeds max_retries (default 5), the message stops retrying and moves to a dead-letter queue named <queue>_dlq. It sits there for inspection instead of consuming worker capacity forever.

Success archives the message; repeated failures raise read_ct until it crosses max_retries and routes to the DLQ.

Dead-lettered messages show up in the dashboard, where you can inspect the payload and the failure. Tune the threshold with max_retries:

Pgbus.configure { |c| c.max_retries = 3 } # DLQ after 3 failed reads

Why a message died#

The pgbus_dead_letter header block.

The process that moves a message to its DLQ — the job worker or the event consumer — writes why into the DLQ copy's PGMQ headers, under the pgbus_dead_letter key (Pgbus::DeadLetterHeader::KEY). Headers the message already carried (trace ids, x-pgmq-group) stay next to it; headers that are not a JSON object are kept under pgbus_original_headers, with a warning in the log. The dashboard's DLQ page, pgbus dlq list|show and the MCP pgbus_dlq tools read it back.

KeyValue
version1 — bumped when the shape changes.
reasonmax_retries_exceeded, the only reason today.
sourceworker (ActiveJob) or consumer (event bus).
source_queueThe physical queue it was deleted from, a priority sub-queue (…_p2) included.
attemptsread_ct of the read that routed it.
max_retriesmax_retries at that moment.
dead_lettered_atUTC ISO 8601.
retries_from_dlqHow many times it was retried out of a DLQ before; absent the first time.
error_classThe last recorded failure, from pgbus_failed_events; absent when none was recorded.
error_messageTruncated to 1,000 characters.
backtraceThe first 10 lines, at most 2,000 characters.
error_attemptThe attempt the error came from. A worker that crashed mid-perform records nothing, so it can be earlier than the last one.
error_recorded_atWhen that failure was recorded.

Retry strips it. Retrying from the DLQ (dashboard, pgbus dlq retry, Retry All) re-enqueues the message without the block — a live message must not claim to be dead — and sets pgbus_dlq_retries to the trip count, so a second death reads "retried from the DLQ once before".

Messages dead-lettered before pgbus 0.18.0 have no block and read "Reason not recorded"; they retry and discard as before. An event's handler error is recorded once the consumer writes pgbus_failed_events rows; until then a dead event says "no handler error was recorded".