DLQ Redrive & Replay-Bypass
A message that exhausts its retries lands on a dead-letter queue carrying the optional
dead_letter block. The
wire contract defines how a message gets there
but deliberately leaves getting it back to tooling. This page is that tooling: the
cross-SDK Redrive operation (ADR-0026) and the Replay-Bypass guard (ADR-0027) that
makes a replay safe to run.
Status: Authoritative · Operator tooling · envelope frozen at
schema_version: 1
Both are tooling layers. Redrive reads and re-publishes the existing frozen envelope;
Replay-Bypass rides an out-of-band transport header, never a new envelope field. The
wire stays at schema_version: 1.
Redrive: move messages off the DLQ, reset
Redrive drains dead-lettered messages from a DLQ and re-publishes each, reset for
reprocessing:
- the
dead_letterblock is removed and top-levelattemptsreset to0; job,trace_id,dataandmetaare preserved verbatim — a redriven message is indistinguishable from a fresh one, and itstrace_idkeeps it on the original trace.
By default each message goes back to its own dead_letter.original_queue; ToQueue
overrides that to a sandbox queue so you can replay safely. Messages are drained from
the DLQ first and then processed, so restored messages are never re-encountered in the
same run; a DLQ message is acked only after its re-publish succeeds, and an undecodable
(“poison”) body is restored, not lost.
The options are the safe-replay primitives:
| Option | Effect |
|---|---|
DryRun |
Inspect and report the plan; every message is restored unchanged, nothing is re-published. |
Select |
A predicate picking which messages to redrive (e.g. by reason or URN); unselected are restored. |
ToQueue |
Re-publish to this queue instead of each message’s source — route to a sandbox to replay safely. |
Max |
Cap how many messages are pulled from the DLQ (0 = all currently available). |
Bypass |
Stamp the replay-bypass header on each redriven message (see below). |
The run returns a result summarizing what happened — counts of redriven vs skipped, and a
per-message item recording its meta.id, trace_id, URN, dead_letter.reason, target
queue, and whether it was actually redriven.
Replay-Bypass: don’t re-fire effects that already ran
Redrive solves getting a message back; it does not solve what its handler does the
second time. A deliberate replay re-runs the handler — and its external side-effects
re-fire: a second charge, a duplicate email.
Idempotency stops an accidental duplicate (same
meta.id delivered twice); Replay-Bypass stops an intended reprocess from
re-firing effects that already happened. They are complementary.
The mechanism (ADR-0027): when Redrive runs with Bypass, it stamps the
bq-replay-bypass header on each redriven message. This is an out-of-band transport
header, not an envelope field — so the wire stays frozen, and it only propagates over a
broker whose transport implements the optional header capability. It rides the same seam
as the traceparent propagation:
the in-memory transport carries it, header-capable bindings carry it, and a binding that does
not yet carry headers makes Bypass a no-op there. The runtime surfaces the marker to the
handler, which wraps its external, non-idempotent side so a replay re-runs the idempotent
core but skips the effects that already fired:
IsReplay(ctx)— is the message currently being handled a deliberate replay?BypassExternalEffects(ctx, fn)— runfnunless this is a replay, in which case skip it.
Go
Redrive(ctx, transport, dlq, RedriveOptions{...}) and the replay guard
(HeaderReplayBypass, IsReplay, BypassExternalEffects) are in babelqueue-go:
import babelqueue "github.com/babelqueue/babelqueue-go"
// dry run: report the plan, change nothing
plan, err := babelqueue.Redrive(ctx, transport, "orders.dlq",
babelqueue.RedriveOptions{DryRun: true})
// redrive to a sandbox, stamping bq-replay-bypass, only messages that failed on timeout
res, err := babelqueue.Redrive(ctx, transport, "orders.dlq", babelqueue.RedriveOptions{
ToQueue: "orders.sandbox",
Bypass: true,
Select: func(env babelqueue.Envelope) bool { return env.DeadLetter != nil && env.DeadLetter.Reason == "failed" },
})
The handler guards its external effects so a replay is safe:
app.Handle("urn:babel:orders:created", func(ctx context.Context, env babelqueue.Envelope) error {
saveOrder(env) // idempotent core — always runs
return babelqueue.BypassExternalEffects(ctx, func() error {
return sendConfirmationEmail(env) // external effect — skipped on replay
})
})
Python
babelqueue.redrive.redrive(transport, dlq, ...) mirrors the Go API; the guard is
is_replay / bypass_external_effects:
from babelqueue import BabelQueue, is_replay, bypass_external_effects
from babelqueue.redrive import redrive
app = BabelQueue("redis://localhost:6379/0")
# inspect, change nothing
plan = redrive(app.transport, "orders.dlq", dry_run=True)
# redrive to a sandbox, stamping bq-replay-bypass
res = redrive(app.transport, "orders.dlq", to_queue="sandbox", bypass=True)
@app.handler("urn:babel:orders:created")
def on_order_created(data, meta):
save_order(data) # idempotent core — always runs
bypass_external_effects(lambda: send_email(data)) # external effect — skipped on replay
PHP
PHP’s Transport is publish-only, so Redrive::run($io, $dlq, $options) drives a
RedriveIO (reserve / ack / publish) that you bind to your broker. The same reset
contract holds — dead_letter removed, attempts to 0, everything else preserved:
use BabelQueue\Redrive\Redrive;
use BabelQueue\Redrive\RedriveOptions;
$result = Redrive::run($io, 'orders.dlq', new RedriveOptions(
toQueue: 'orders.sandbox', // safe sandbox replay
dryRun: false,
bypass: true, // stamp bq-replay-bypass (needs a HeaderRedriveIO)
select: fn (array $env) => ($env['dead_letter']['reason'] ?? '') === 'failed',
));
Redrive::reset($envelope) is exposed on its own for callers that re-publish through their
own machinery.
The replay-bypass guard reads the marker off the delivered message (a HasHeaders), so the
handler skips effects that already fired — the PHP mirror of Go’s BypassExternalEffects:
use BabelQueue\Redrive\ReplayBypass;
$handler = ReplayBypass::wrap(function (\BabelQueue\Contracts\ConsumedMessage $m): void {
saveOrder($m); // idempotent core — always runs
ReplayBypass::bypassExternalEffects($m, fn () => sendEmail($m)); // skipped on replay
});
Per-SDK
Redrive ships across the SDKs with the same reset contract and safe-replay options, and the
Replay-Bypass core guard is now in all six — the bq-replay-bypass marker is identical
across SDKs, so it is cross-SDK. The behaviour is the same everywhere; only the surfacing
differs by language: Go/Python carry the replay flag on the handler’s context, Java on a
ThreadLocal scope, and Node/.NET/PHP pass the delivered headers explicitly (PHP’s core
Transport is publish-only, so its guard is a header-reading helper like Idempotent::wrap,
not a context-based one).
| SDK | Redrive | Replay-Bypass guard |
|---|---|---|
| Go | Redrive(ctx, t, dlq, RedriveOptions{…}) |
IsReplay / BypassExternalEffects |
| Python | redrive(transport, dlq, …) |
is_replay / bypass_external_effects |
| Java | Redrive.redrive(t, dlq, opts) |
Replay.isReplay / Replay.bypassExternalEffects (Replay.process) |
| Node | resetForRedrive + RedriveIO.publishWithHeaders |
isReplay(headers) / bypassExternalEffects(headers, fn) |
| .NET | Redrive.RedriveAsync(t, dlq, opts) (IHeaderPublisher) |
Replay.IsReplay(headers) / Replay.BypassExternalEffectsAsync(headers, fn) |
| PHP | Redrive::run($io, $dlq, $options) (bypass: true) |
ReplayBypass::isReplay / bypassExternalEffects / wrap |
The one honest limit is the transport layer, not the SDK: the marker only propagates over
a broker whose transport carries out-of-band headers (the in-memory transport does;
HeaderPublisher-capable bindings carry it, others fall back). Until a given broker binding
wires the header, Bypass is a best-effort no-op on it and sandbox ToQueue routing remains
the fallback — exactly the per-transport rollout shape the
traceparent propagation
follows, since both ride the same header seam.
For a runnable walkthrough, see the DLQ redrive example, and for the related de-duplication primitive, Idempotency.
See each SDK’s reference: Go, Python, PHP, Java, Node, .NET.