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.
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:
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
endThe 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:
class FragileApiJob < ApplicationJob
include Pgbus::RetryBackoff::JobMixin
pgbus_retry_backoff base: 10, max: 600, jitter: 0.2
def perform(...)
# ...
end
endThe 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.
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 readsWhy 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.
| Key | Value |
|---|---|
version | 1 — bumped when the shape changes. |
reason | max_retries_exceeded, the only reason today. |
source | worker (ActiveJob) or consumer (event bus). |
source_queue | The physical queue it was deleted from, a priority sub-queue (…_p2) included. |
attempts | read_ct of the read that routed it. |
max_retries | max_retries at that moment. |
dead_lettered_at | UTC ISO 8601. |
retries_from_dlq | How many times it was retried out of a DLQ before; absent the first time. |
error_class | The last recorded failure, from pgbus_failed_events; absent when none was recorded. |
error_message | Truncated to 1,000 characters. |
backtrace | The first 10 lines, at most 2,000 characters. |
error_attempt | The attempt the error came from. A worker that crashed mid-perform records nothing, so it can be earlier than the last one. |
error_recorded_at | When 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".