Retry Logic & Dead Letter Queues

As part of the Pipeline Automation & Batch Processing layer, retry logic and a dead letter queue form the fault-tolerance backbone that decides which failures the pipeline absorbs silently and which it quarantines for a human. This component protects a throughput-and-integrity SLA: a network blip during remote ingest, a transient codec library crash, or an ephemeral storage stall must never corrupt a deliverable, orphan an intermediate file, or stall the batch — yet a genuinely unrecoverable job must stop retrying before it burns GPU quota and be sealed for analysis. Get this layer wrong and transient errors either cascade into half-written outputs or hammer a recovering upstream into a second outage; get it right and the rest of the pipeline can assume that everything past the retry boundary either succeeded or is safely in the DLQ.

Lifecycle states of a single retried media task A task is leased from the queue into the Claimed state under acks_late, then enters Running where ffmpeg or a probe executes. A transient failure routes to a backoff timer — attempt 1 to N with an exponential, jittered, capped delay — which feeds the task back into Running. Success emits a content-addressed artifact and acks the message. A permanent error or an exhausted retry budget routes the task to the dead letter queue as a schema-validated sealed record. A separate, manual-only replay path returns a DLQ record to the Claimed state through a dry-run plus idempotency guard. transient failure retry after delay leased · acks_late success permanent / budget exhausted manual replay only — re-claim if guard passes Claimed leased from queue Running ffmpeg / probe executes Backoff timer attempt 1‥N base·2ⁿ + jitter, capped Success artifact emitted · acked Dead letter queue sealed record schema-validated full payload + trace Replay guard dry-run + idempotency check

Prerequisites & Environment

This component assumes the broker-backed task fabric described in Celery task routing for video jobs and the scheduler in orchestrating pipelines with Airflow. Retries and DLQ routing are cross-cutting machinery layered on top of both, so the environment below is deliberately minimal.

Requirement Version / value Notes
Python 3.10+ Uses match statements and `
celery 5.3.6 autoretry_for, retry_backoff, acks_late
redis 5.0.1 Broker + result backend; also the DLQ store here
tenacity 8.2.3 Backoff for non-Celery helper calls (probes, uploads)
pydantic 2.6.1 Enforces the DLQ payload schema at the boundary
prometheus-client 0.20.0 Exposes retry/DLQ counters
ffmpeg 6.1 (pinned in the worker image) Exit codes drive permanent-vs-transient classification

Pin every version in the worker image rather than the host; reproducible retries depend on byte-identical binaries, which is exactly what dockerizing media processing containers guarantees. The relevant environment variables:

Variable Purpose Example
CELERY_BROKER_URL Broker connection redis://broker:6379/0
DLQ_REDIS_URL Durable DLQ store (separate db) redis://broker:6379/9
MAX_RETRIES_GPU Attempt cap for GPU transcodes 3
MAX_RETRIES_CPU Attempt cap for metadata/probe tasks 15
RETRY_BACKOFF_BASE Base seconds for exponential backoff 2
RETRY_BACKOFF_MAX Ceiling on any single delay (s) 600

Architecture & Queue Topology

Retries and the DLQ sit beside the work queues rather than inside them. A worker claims a task with acks_late=True, so the broker only drops the message once the task either succeeds or is explicitly acknowledged after being sealed into the DLQ — a crash mid-transcode therefore redelivers rather than vanishes. Transient failures are re-scheduled by the worker onto the same queue after a computed delay; permanent failures and budget exhaustion are published to a dedicated media.dlq Redis stream. A separate, low-concurrency replay consumer drains the DLQ on demand, never automatically, so a poison payload can never form a tight retry loop.

Queue topology for retries, DLQ routing, and manual replay A message leaves the work queue (gpu.transcode or cpu.metadata) into a worker running with acks_late, which classifies the failure. Success acks the message and emits an artifact. A transient failure goes to the backoff scheduler — exponential delay with jitter, capped — and is re-scheduled onto the same work queue. A permanent error or exhausted budget is published to the media.dlq Redis stream, a separate Redis db 9 with a 14-day TTL holding schema-sealed records. Draining the DLQ is manual only: a low-concurrency replay consumer passes each record through a dry-run guard that validates the payload hash and codec availability before re-enqueueing onto the work queue. success permanent / exhausted transient re-schedule manual drain re-enqueue if ok Work queue gpu.transcode cpu.metadata Worker acks_late=True Classify failure type Backoff scheduler base·2ⁿ + jitter, capped per job class Success ack message · emit content-addressed artifact media.dlq Redis stream · db 9 · 14-day TTL schema-sealed record never auto-replayed Replay consumer concurrency 1 · on demand Dry-run guard validate hash + codec

The key invariant: a task can only leave the retry loop in one of two ways — success, or a sealed DLQ record. There is no third “logged and forgotten” path, because silent drops are precisely what make media batches non-reproducible.

Step-by-Step Implementation

1. Define a per-job-class retry policy

Different media work has different economics. A ffprobe metadata pass is cheap and flaky, so it tolerates many fast attempts; a 4K transcode is expensive, so it caps low and backs off hard. Encode that as data, not as scattered decorators.

# celery==5.3.6, python>=3.10
from dataclasses import dataclass

@dataclass(frozen=True)
class RetryPolicy:
    max_retries: int
    backoff_base: int      # seconds
    backoff_max: int       # ceiling on a single delay
    jitter: bool

POLICIES: dict[str, RetryPolicy] = {
    "cpu.metadata":  RetryPolicy(max_retries=15, backoff_base=2,  backoff_max=60,  jitter=True),
    "gpu.transcode": RetryPolicy(max_retries=3,  backoff_base=10, backoff_max=600, jitter=True),
    "io.upload":     RetryPolicy(max_retries=8,  backoff_base=2,  backoff_max=120, jitter=True),
}

Verify the policy table loads and is exhaustive for your routed queues:

python -c "from policies import POLICIES; assert {'cpu.metadata','gpu.transcode','io.upload'} <= POLICIES.keys(); print('ok')"

2. Make the task idempotent with a content key

A retry must be safe to run twice. Derive a deterministic output key from the input content hash so a re-execution overwrites the same object instead of appending a duplicate, and short-circuit if the artifact already exists. The asset_sha256 contract comes straight from the upstream gate described in media validation and error routing.

# python>=3.10
from pathlib import Path

def transcode_output_key(asset_sha256: str, profile: str) -> str:
    # Deterministic: same input + profile always maps to the same object.
    return f"derived/{profile}/{asset_sha256}.mp4"

def already_done(store, key: str) -> bool:
    return store.exists(key)  # idempotency guard before any expensive work

Verify two runs of the same payload produce one object, not two:

python -m pytest tests/test_idempotency.py::test_double_run_single_artifact -q

3. Apply exponential backoff with jitter

Let Celery own the retry scheduling and compute the delay from the policy. Randomized jitter spreads retries across a window so a fleet of workers does not stampede a recovering media server in lockstep.

# celery==5.3.6
import random
from celery import shared_task
from policies import POLICIES

@shared_task(
    bind=True,
    acks_late=True,                 # redeliver on worker crash
    autoretry_for=(TransientMediaError,),
    retry_backoff=True,             # base*2**(n-1)
    retry_jitter=True,              # full jitter
    max_retries=POLICIES["gpu.transcode"].max_retries,
    retry_backoff_max=POLICIES["gpu.transcode"].backoff_max,
)
def transcode(self, payload: dict) -> dict:
    if already_done(store, transcode_output_key(payload["asset_sha256"], payload["profile"])):
        return {"status": "success", "skipped": True}
    return run_ffmpeg(payload)      # raises TransientMediaError or PermanentMediaError

Verify the delay sequence grows and stays under the ceiling:

python -c "import policies as p; b=p.POLICIES['gpu.transcode']; \
print([min(b.backoff_base*2**(n), b.backoff_max) for n in range(b.max_retries+1)])"
# -> [10, 20, 40, 80]  (80 < 600 ceiling)

4. Route exhausted tasks to the DLQ with a sealed payload

When max_retries is hit, or a PermanentMediaError (bad container, missing codec, auth failure) is raised, seal the full execution context into the DLQ. The payload is validated against a Pydantic model before it is written, so a malformed DLQ record can never itself become a debugging dead end.

# pydantic==2.6.1, redis==5.0.1
from datetime import datetime, timezone
from pydantic import BaseModel, Field

class DLQRecord(BaseModel):
    job_id: str
    queue: str
    attempt_count: int = Field(ge=1)
    error_code: str                       # e.g. ffmpeg exit code or "AUTH_401"
    error_class: str                      # "permanent" | "exhausted"
    original_payload_hash: str            # ties back to asset_sha256
    payload: dict
    stack_trace: str
    enqueued_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())

def to_dlq(dlq, record: DLQRecord) -> None:
    dlq.xadd("media.dlq", {"body": record.model_dump_json()})  # schema-validated on construct

Hook it into the task’s failure handler:

@transcode.on_failure
def seal(self, exc, task_id, args, kwargs, einfo):
    record = DLQRecord(
        job_id=task_id, queue="gpu.transcode",
        attempt_count=self.request.retries + 1,
        error_code=getattr(exc, "code", "UNKNOWN"),
        error_class="exhausted" if self.request.retries >= self.max_retries else "permanent",
        original_payload_hash=args[0]["asset_sha256"],
        payload=args[0], stack_trace=str(einfo),
    )
    to_dlq(dlq, record)

Verify a forced permanent failure lands exactly one well-formed record:

redis-cli -n 9 XLEN media.dlq        # expect it to increment by 1
redis-cli -n 9 XRANGE media.dlq - + COUNT 1   # inspect the sealed payload

5. Replay DLQ entries under a dry-run guard

Recovery is deliberate, never automatic. A replay validates the hash, confirms the codec is available again, runs a dry pass, and only then re-enqueues — so restoring an upstream dependency does not unleash a flood of unverified jobs.

# python>=3.10
def replay(dlq, entry_id: str, *, dry_run: bool = True) -> str:
    raw = dlq.xrange("media.dlq", entry_id, entry_id)[0][1]["body"]
    record = DLQRecord.model_validate_json(raw)
    if record.payload["asset_sha256"] != record.original_payload_hash:
        return "rejected: payload tampered"
    if not codec_available(record.payload["codec"]):
        return "deferred: codec still missing"
    if dry_run:
        return "dry-run ok"
    transcode.apply_async(args=[record.payload], queue=record.queue)
    dlq.xdel("media.dlq", entry_id)
    return "re-enqueued"

Verify a dry-run replay touches no work queue:

python -m pytest tests/test_replay.py::test_dry_run_does_not_enqueue -q

Data Contracts

Every DLQ record is the source of truth for a failed job; downstream triage tooling depends on these fields existing and being typed exactly so it can reconstruct state without any volatile in-memory cache.

Field Type Validation rule Example value
job_id string broker task id; unique c1f0…9b
queue string the originating work queue gpu.transcode
attempt_count int >= 1; equals retries + 1 4
error_code string ffmpeg exit code or app code AUTH_401
error_class enum permanent / exhausted exhausted
original_payload_hash string 64-char hex; matches asset_sha256 9f2c…a1
payload object the full original job payload { "profile": "h264-1080p", … }
stack_trace string non-empty for permanent failures Traceback …
enqueued_at string ISO-8601 UTC 2026-06-27T11:04:22Z

Resilience Patterns

The retry policy is bounded exponential backoff with full jitter, capped per job class so a recovering upstream is never stampeded and a 4K transcode never silently consumes more than its three attempts. Idempotency is guaranteed by deriving the output key from the input content hash and short-circuiting when the artifact already exists, which makes every retry — and every replay — safe to run more than once. DLQ routing is the terminal state for anything the loop cannot fix: permanent errors are sealed immediately, exhausted budgets are sealed on the final failure, and both carry the full payload plus stack trace. The acks_late setting closes the last gap, ensuring a worker that dies mid-job causes a redelivery rather than a silent loss. Together these turn an unreliable distributed system into one with exactly two terminal outcomes per task: a verified artifact, or a triage-ready DLQ record.

Observability & Debugging

Instrument the boundary, not the happy path. The signals that matter are retry pressure (are we masking a degrading dependency?) and DLQ depth (is something systemically broken?).

# prometheus-client==0.20.0
from prometheus_client import Counter, Gauge

RETRIES = Counter("media_task_retries_total", "Retries", ["queue", "error_code"])
DLQ_ENQUEUED = Counter("dlq_messages_enqueued_total", "DLQ inserts", ["queue", "error_class"])
DLQ_DEPTH = Gauge("dlq_depth", "Current DLQ length", ["queue"])
FAIL_BY_CODEC = Counter("transcode_failure_by_codec_total", "Failures", ["codec", "error_code"])
Symptom Root cause Action
media_task_retries_total ramps on one queue upstream media server rate-limiting or storage latency widen backoff, add a request budget; check the dependency, not the worker
dlq_depth climbs steadily systemic failure (missing codec, bad credentials, persistent I/O) stop replaying; audit DLQ payloads before the SLA breaches
Same job_id retried forever non-idempotent task or acks_late without a budget cap enforce the content-key guard and a finite max_retries
WorkerLostError then redelivery storm OOM-killed worker on 4K/HDR drop GPU concurrency to 1, raise the VRAM ceiling
DLQ record missing stack_trace failure raised outside the sealed handler route all permanent paths through on_failure

Differentiate expected transient spikes (a brief CDN rate limit) from systemic degradation (storage latency climbing for ten minutes) at the alert layer: page on sustained dlq_depth growth, not on a single retry burst.

Performance Tuning

Set worker_prefetch_multiplier=1 for long media jobs so a worker reserves only the task it is actively processing — otherwise a crash forfeits a whole batch of prefetched work to redelivery. Keep GPU queues at -c 1 (one transcode per VRAM budget) and let the backoff ceiling, not concurrency, absorb retry pressure. Size RETRY_BACKOFF_MAX against the realistic recovery time of the slowest upstream: 600 seconds suits a media origin that occasionally restarts, but pushing it to thousands of seconds just hides outages that should page. Give the DLQ store a TTL (14 days is a sane default) so triaged records expire rather than growing unbounded, and run the replay consumer at concurrency 1 so recovery is paced and observable rather than a thundering re-enqueue.

Frequently Asked Questions

When should a job retry versus go straight to the DLQ?

Classify the failure at the raise site. Network timeouts, HTTP 429/503 from a media origin, transient codec-library crashes, and storage I/O stalls are TransientMediaError and belong in the retry loop. Malformed containers, missing codecs, schema-invalid payloads, and auth failures (401/403) are PermanentMediaError and should be sealed into the DLQ on the first occurrence — retrying them only wastes compute and delays triage.

Why use jitter instead of plain exponential backoff?

Without jitter, every worker that failed against the same upstream at the same moment retries at the same computed delay, producing synchronized waves that re-overwhelm the recovering service. Full jitter spreads each retry uniformly across its backoff window, smoothing the load so the dependency can actually recover. Celery's retry_jitter=True applies this on top of retry_backoff.

How do I guarantee a retried transcode does not duplicate output?

Derive the output object key deterministically from the input asset_sha256 plus the profile, and check for the artifact's existence before doing any work. A retry then either finds the completed object and short-circuits, or overwrites the same key — never a second, divergent file. This is what makes acks_late redelivery safe.

Why store the DLQ in Redis Streams rather than just logging failures?

A log line is unstructured and unreplayable. A Redis Stream record preserves the full original payload, attempt count, error class, and stack trace as a typed, ordered entry you can range-query, replay under a dry-run guard, and expire with a TTL. The Pydantic model enforced on write means every record is reconstructable — you can re-run the exact failed job locally without guessing its inputs.

Should the DLQ replay ever be automatic?

No. Automatic replay is how a single poison payload becomes an infinite loop. Drain the DLQ with a deliberate, low-concurrency consumer that validates the payload hash, confirms the codec is available again, runs a dry pass, and only then re-enqueues. Recovery should be paced and observable, triggered after you have confirmed the upstream cause is fixed.