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_letter block is removed and top-level attempts reset to 0;
  • job, trace_id, data and meta are preserved verbatim — a redriven message is indistinguishable from a fresh one, and its trace_id keeps 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) — run fn unless 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.