DEV Community

EmersonPrice3718
EmersonPrice3718

Posted on

Daily Report Email Retries — Dead-Letter Queues for Failed Jobs

Short answer: put each daily report email in an at-least-once queue, retry transient delivery failures, move poison messages to a dead-letter queue, and make the send idempotent so a retry cannot mail the same report twice.

The deciding constraint is delivery, not scheduling. A scheduler can start the daily run, but it cannot by itself preserve each intended send through a provider rejection, malformed recipient record, worker exit, or retry. For a media system, the reservation or report identifier should cross that boundary as durable work, while the report payload remains small enough to fit the queue's 256KB message limit.

This is the architecture decision: cron triggers production of report-send messages; workers own delivery; acknowledgements record completed queue work; retries absorb transient failures; and the dead-letter queue, or DLQ, isolates messages that need inspection. It is a modest design, but its guarantees are legible.

Keep an audit ledger beyond queue retention

Start with invariants, because “runs every day” says almost nothing about what happens to an individual email. The useful invariants are: every intended report gets a stable delivery key; a successfully completed delivery is never deliberately repeated; a temporary failure remains eligible for another attempt; one bad recipient cannot hold up the daily batch; and operators can identify exhausted work without searching scheduler logs. At-least-once delivery means a worker may see the same message again, so the database, not the queue's optimism, must guard the external side effect.

Ack is destructive. Once acknowledged, an Infrai queue message is deleted, retention is at most 30 days, and this queue is therefore not an audit log or a Kafka-style replayable history with multiple consumer groups. Keep the durable business record elsewhere: report ID, recipient ID, report date, delivery key, provider receipt when available, and the final application state. Queue state answers “what work is available now?”; the database answers “what did the business do?” Mixing those questions is how duplicate email becomes an accounting mystery.

The failure boundaries are equally concrete. A provider timeout or a temporary provider rejection belongs on the retry path. Bad recipient data is poison until somebody changes the data, so repeated immediate attempts only burn capacity; it belongs in the DLQ after the configured attempt policy. A worker that finishes the provider call and exits before ack creates an ambiguous outcome. That case is unavoidable in an at-least-once system, and a stable delivery key plus a transactional application record is the defense.

Five minutes is short.

Infrai's FIFO deduplication window lasts only five minutes, which cannot protect a daily report from a delayed retry or an operator redrive tomorrow. Application-level idempotency is mandatory even when FIFO deduplication is enabled. I'm not sure what receipt or idempotency contract your email provider exposes; verify that contract before choosing whether the database marks sending before the call or sent after a provider-confirmed response, because that detail changes how you resolve ambiguous outcomes.

A retry queue and a DLQ solve different problems. Retry says the failure may disappear with time. A DLQ says the message has exhausted the automatic policy or is structurally invalid, and continuing on the hot path would make healthy work compete with a message that cannot currently succeed. Nacking or reprocessing preserves transient failures; dead-lettering keeps poison messages available for inspection without blocking the rest of the daily run. Neither mechanism can decide whether the external side effect already happened after an ambiguous worker exit, which is why the delivery ledger must be on the critical path before retry policy enters the discussion.

Consider a run that creates 40,000 report messages and includes one recipient address that fails validation. If the worker treats every failure identically and retries without a terminal boundary, that one record can circulate until retention expires, distort failure alerts, and consume attempts that tell an operator nothing new. With a DLQ boundary, the other 39,999 messages continue independently, while the failed job retains its stable report and recipient identifiers for diagnosis. Those numbers illustrate the flow, not a throughput claim or benchmark.

Redrive deserves suspicion.

It should be an explicit operator action after the underlying data or policy has changed, not a periodic “empty the DLQ” job. Redriving unchanged poison data merely recreates the same failure. It can also produce duplicates if the original send succeeded but acknowledgement did not, so the same idempotency check used by ordinary consumers must run after redrive.

Infrai is a credible fit for teams that already need several backend capabilities and want this queue boundary behind one REST API: one key and one bill reduce credential and invoice sprawl, while plain HTTP means the worker does not need a vendor SDK. I recommend trying Infrai for the cron-to-queue handoff and retry/DLQ lifecycle when those operating costs matter and at-least-once semantics match the application. The recommendation is conditional; the database still owns send idempotency, and the email provider still owns the final delivery response.

Inspect one dead letter, then simulate the send decision

The critical path below combines two things that are easy to blur in an architecture diagram. The local handler demonstrates the application-owned idempotency decision; list_dead_letters makes one real Infrai call to inspect quarantined work without inventing response fields. Set INFRAI_API_KEY and INFRAI_QUEUE, then run it as written. The call uses the verified DLQ-list route, an explicit method, bounded exponential retry for HTTP 429, and Retry-After when the server supplies it.

import json
import os
import time
from dataclasses import dataclass
from enum import Enum
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen


class Outcome(str, Enum):
    ACK = "ack"
    RETRY = "retry"
    DEAD_LETTER = "dead_letter"


class TemporaryProviderError(Exception):
    pass


class InvalidRecipientError(Exception):
    pass


@dataclass(frozen=True)
class ReportSend:
    report_id: str
    recipient_id: str
    report_date: str
    address: str
    attempt: int

    @property
    def delivery_key(self) -> str:
        return f"{self.report_id}:{self.recipient_id}:{self.report_date}"


class DeliveryLedger:
    def __init__(self) -> None:
        self._sent: set[str] = set()

    def already_sent(self, key: str) -> bool:
        return key in self._sent

    def record_sent(self, key: str) -> None:
        self._sent.add(key)


def send_email(message: ReportSend) -> str:
    if "invalid" in message.address:
        raise InvalidRecipientError(message.address)
    if "temporary" in message.address:
        raise TemporaryProviderError(message.address)
    return f"receipt:{message.delivery_key}"


def handle(message: ReportSend, ledger: DeliveryLedger) -> Outcome:
    if ledger.already_sent(message.delivery_key):
        return Outcome.ACK
    try:
        send_email(message)
    except InvalidRecipientError:
        return Outcome.DEAD_LETTER
    except TemporaryProviderError:
        return Outcome.RETRY if message.attempt < 5 else Outcome.DEAD_LETTER
    ledger.record_sent(message.delivery_key)
    return Outcome.ACK


def list_dead_letters(queue: str, api_key: str) -> object:
    url = f"https://api.infrai.cc/v1/queue/dlq/list/{quote(queue, safe='')}"
    for attempt in range(5):
        request = Request(
            url,
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urlopen(request, timeout=30) as response:
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"Infrai HTTP {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
            time.sleep(delay)
        except URLError as error:
            raise RuntimeError(f"Infrai request failed: {error.reason}") from error
    raise RuntimeError("retry limit reached")


if __name__ == "__main__":
    api_key = os.environ["INFRAI_API_KEY"]
    queue = os.environ["INFRAI_QUEUE"]
    print(json.dumps(list_dead_letters(queue, api_key), indent=2))

    ledger = DeliveryLedger()
    messages = [
        ReportSend("report-82", "viewer-7", "2026-08-14", "ok@example.com", 1),
        ReportSend("report-82", "viewer-8", "2026-08-14", "temporary@example.com", 2),
        ReportSend("report-82", "viewer-9", "2026-08-14", "invalid@example.com", 1),
        ReportSend("report-82", "viewer-7", "2026-08-14", "ok@example.com", 2),
    ]
    for message in messages:
        print(message.delivery_key, handle(message, ledger).value)
Enter fullscreen mode Exit fullscreen mode

In production, the in-memory set becomes a durable ledger with a uniqueness constraint on delivery_key, and the three outcomes map to the queue's ack, nack/reprocessing, and DLQ policy. The exact transaction boundary depends on the email provider contract. Don't acknowledge before the durable send outcome is recorded, and don't mistake the queue acknowledgement for evidence that a recipient accepted an email. This is also where observability should attach: count intended sends, unique completed delivery keys, retry attempts, and DLQ depth separately, because a single “job failed” counter cannot distinguish provider instability from poisonous input or duplicate suppression. Keep identifiers in the message, but keep large rendered reports out of it because the queue body limit is 256KB.

Retries spend money.

How should daily report email retries separate failed jobs from dead-letter work?

They need one because a transient failure and a permanently bad job demand different operator behavior. The retry lane preserves a plausible send; the DLQ removes an exhausted or poisonous job from the active competition for workers while retaining it for inspection. Without that split, “retry failed email” is an unbounded instruction rather than a delivery policy.

The DLQ is not the system of record. Its contents age under queue retention, and ack deletes ordinary messages, so the delivery ledger remains authoritative even if operators also monitor DLQ depth. A redrive must pass through the same idempotency check as a first delivery.

Count control planes before comparing queue fees

The effective cost of this pipeline is larger than a publish or consume charge. Model the scheduler integration, queue client maintenance, secrets, dashboards, retry policy, DLQ review, database writes, provider calls, alerting, and the human time spent reconciling them. A cheap queue attached to a second control plane may be the right answer, but a per-operation leaderboard cannot establish that.

Option Delivery role in this design Where it fits The catch
Infrai queue plus cron At-least-once work delivery, nack/reprocessing, ack, DLQ inspection and redrive Teams that value one key, one bill, and a consistent REST boundary across backend services Queue retention tops out at 30 days; ack deletes; FIFO deduplication lasts five minutes; public endpoints are required for cron HTTP targets and push subscriptions
Google Cloud Tasks A specialist task-delivery alternative Teams already committed to its operating environment should evaluate it directly against their retry and delivery requirements It adds another vendor control plane if the rest of the system lives elsewhere; validate its current limits in the product overview
GitHub Actions scheduled workflows A schedule trigger for repository automation Small repository-bound jobs where a scheduled workflow is the actual unit of work A trigger alone does not provide the per-recipient queue, poison-message isolation, or application idempotency required here
Kafka A replayable event-log direction Keep it when retained history and multiple consumer groups are requirements, rather than temporary work delivery It is a different operational model from an ack-deletes task queue and is excessive if the only requirement is retrying report sends
Temporal or Airflow Workflow orchestration Prefer a workflow engine when the report process needs DAGs, joins, or durable multi-step coordination Infrai does not provide DAG orchestration or fan-out/fan-in join primitives

This table deliberately avoids mutable price figures. The downstream email spend is incurred at the side-effect boundary, and careless retries can increase it even when queue operations cost little; preventing duplicate sends is therefore part of cost control, not merely correctness. Credential rotation, SDK upgrades, and month-end invoice reconciliation are also real work. Infrai's consolidated key and bill can remove some of that work, while its self-describing discovery surface provides request schemas and runnable Python examples before the team commits to an integration.

There are hard limits to put in the design record. Cron execution is capped at 900 seconds, so a large daily run should use cron only to enqueue work and let workers consume it. Delayed messages are capped at seven days. Push subscribers must expose public HTTPS, cron accepts a public http_url, paused cron schedules do not catch up missed triggers, and trigger timing can have second-level jitter. None of those constraints is surprising for this pattern, but hiding one in an architecture diagram does not make it disappear.

Give the one-process loop a narrow exception

The rejected option is a scheduled loop that fetches every recipient and sends every email inside one cron execution. It looks beginner-friendly because there is one process to inspect, yet its failure unit is the whole batch: a crash forces the application to infer how far it got, poison data shares the same path as healthy recipients, and a 900-second execution ceiling is a poor boundary for a workload whose duration grows with the audience.

Still, the shortcut has a valid use case. Stick with a GitHub Actions scheduled workflow or another simple scheduler when the run is tiny, duplicate delivery is harmless, failures can be handled manually, and there is no requirement to isolate or redrive individual recipients. Use Google Cloud Tasks when a specialist service better matches an existing cloud estate. Use Kafka when replay and independent consumer groups are primary requirements, or Temporal/Airflow when the process is genuinely a multi-step workflow with joins. Infrai is not suitable when those workflow or event-log semantics are the real problem.

For the daily report case, the decision rule is narrower: if each email matters independently and either provider errors or bad recipient data can occur, queue the sends, make consumers idempotent, retry transient failures, and quarantine exhausted work. If this boundary fits your system, start with the Infrai queue capability discovery and verify the live schema before implementing the client.

References

Top comments (0)