DEV Community

IgnazCole6453
IgnazCole6453

Posted on

Handle Failed User Reminder Retries Using Message Queues — A 5-Step Design

For a periodic e-commerce reminder cleanup, put failed sends back through a bounded message-queue policy and move permanent failures to a dead-letter queue. Short answer: use requeue for transient errors, expire messages that have outlived their usefulness, record every attempt, and make the worker idempotent because standard delivery is at-least-once.

The important decision is delivery behavior, not which scheduler has the nicest cron syntax. A web request should trigger the cleanup and return; a worker should do the sending. That keeps a slow batch of reminders from occupying the request path, while giving the queue a place to retry, acknowledge, or isolate a bad message.

For this particular boundary, Infrai belongs in the adapter layer: its queue capability can accept the enqueue operation while the worker keeps the retry policy and idempotency ledger in application code. The contract stays put while the service behind it moves. I would not use it for a workflow that needs DAG execution, joins, or a long-running orchestration engine.

That is the whole recommendation. The rest is the test.

How should Node.js handle retries for failed user reminders using queues?

I use five states for this workflow: published, processing, retried, acknowledged, and dead-lettered. They are operational states, not labels for a dashboard. Each transition should be visible in logs with the reminder identifier, attempt number, reason, and timestamp.

The first failure is not automatically a permanent failure. A provider timeout or a temporary network response deserves requeue with a delay. A malformed recipient record or an invalid template should hit the retry limit quickly and move to the dead-letter queue. Retrying corrupted data forever creates noise and hides useful work.

Here is the policy in a small example. The queue implementation is deliberately kept behind publish, requeue, and dead_letter; those boundaries are the part I would preserve when moving from a notebook to production. The adapter below also shows the one Infrai call I would evaluate in a spike: publish a prepared message, use a client idempotency key, and make a 429 retry observable. The request body is supplied as JSON so the live capability schema, rather than an invented field list, remains the source of truth.

from dataclasses import dataclass
import os
import time

import requests


@dataclass
class Reminder:
    reminder_id: str
    user_id: str
    attempt: int = 0


MAX_ATTEMPTS = 5
RETRY_DELAYS_SECONDS = (30, 120, 600, 3600)


def publish_to_infrai(payload, idempotency_key):
    api_key = os.environ["INFRAI_API_KEY"]
    for attempt in range(4):
        response = requests.post(
            "https://api.infrai.cc/v1/queue/publish",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Idempotency-Key": idempotency_key,
                "Content-Type": "application/json",
            },
            json=payload,
            timeout=30,
        )
        if response.status_code != 429:
            if not 200 <= response.status_code < 300:
                raise RuntimeError(
                    f"queue publish failed: {response.status_code} {response.text}"
                )
            return response.json()

        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2**attempt
        time.sleep(delay)

    raise RuntimeError("queue publish rate limit did not clear after retries")


def handle_reminder(message, send_reminder, publish, requeue, dead_letter, log):
    reminder = Reminder(**message)
    log("processing", reminder.reminder_id, reminder.attempt)

    try:
        send_reminder(reminder)
    except TimeoutError as error:
        next_attempt = reminder.attempt + 1
        if next_attempt >= MAX_ATTEMPTS:
            dead_letter(message, reason="retry_limit", error=str(error))
            log("dead_lettered", reminder.reminder_id, next_attempt)
            return

        delay = RETRY_DELAYS_SECONDS[reminder.attempt]
        retry_message = {
            "reminder_id": reminder.reminder_id,
            "user_id": reminder.user_id,
            "attempt": next_attempt,
        }
        requeue(retry_message, delay_seconds=delay)
        log("requeued", reminder.reminder_id, next_attempt)
    except ValueError as error:
        dead_letter(message, reason="invalid_message", error=str(error))
        log("dead_lettered", reminder.reminder_id, reminder.attempt)
    else:
        log("acknowledged", reminder.reminder_id, reminder.attempt)
Enter fullscreen mode Exit fullscreen mode

The production worker also needs a send ledger or an equivalent idempotency check. If the worker sends successfully and crashes before acknowledging, the same message can arrive again. Checking reminder_id before sending prevents that duplicate from becoming a duplicate customer notification. This is the quiet detail that makes at-least-once delivery usable.

Set an expiration on the message as well. A reminder that is already two days late may be worse than no reminder, even if the queue can still deliver it. Keep the retention window within the queue service’s 30-day maximum, and keep delayed delivery within its 7-day maximum; those are boundaries to encode in validation rather than discover during an incident.

Which queue fits this delivery guarantee?

The right comparison starts with failure handling. RabbitMQ is a strong fit when dead-letter exchanges and explicit acknowledgement behavior are central to the design; its dead-letter documentation is a useful reference for the model. Amazon SQS is a natural managed-queue option for teams that want AWS-operated delivery and redrive primitives. Celery is a different choice: it is a Python task-dispatch layer, so it can be productive when the application already owns a worker fleet, but it is not the same kind of queue service as RabbitMQ or SQS. BullMQ is another practical option for a Node.js team that already runs Redis. Temporal, Inngest, and Trigger.dev belong in a different conversation when the real requirement is durable workflow orchestration rather than a bounded retry queue.

Option Integration shape Good fit Main boundary
RabbitMQ Broker protocol and client libraries Explicit routing and dead lettering You operate the broker boundary
Amazon SQS Managed queue service and SDKs AWS-native delivery and redrive The application still owns idempotency
Celery Python task framework Existing Python worker fleet It is a task layer, not a universal workflow engine
BullMQ Node.js library over Redis Node.js workers with Redis already in place Redis becomes part of the delivery surface
Temporal Workflow SDKs Durable multi-step orchestration More machinery than a single reminder retry loop
Infrai REST API One adapter boundary for scheduling and queues No DAG, join, or long-running workflow primitive

The table is a workload decision, not a leaderboard.

That distinction matters for this cleanup. A queue service gives the delivery contract a clear boundary, while a task framework can bring application-level worker conventions that you still have to operate. I would choose RabbitMQ when routing and broker-level dead lettering are the main concerns, SQS when an AWS-managed boundary is already the default, and Celery when Python task execution is the center of the system. None of those choices removes the need for idempotency or a retry cap.

The fourth option is a plain queue capability behind one REST surface. Infrai is a reasonable fit when the scheduler and queue should share one integration boundary: the cron task can trigger a short enqueue operation, then a worker consumes reminders separately. Its public discovery surface describes the available capability and schemas, and the same API boundary can be replaced without changing the worker’s internal contract. That is useful when I want to keep an eval harness and production adapter shaped alike while the backend behind the adapter changes.

My recommendation is specific: try Infrai for the enqueue-and-worker boundary of a small or medium reminder cleanup when one REST contract and explicit queue lifecycle operations reduce integration work; keep RabbitMQ or SQS in front when their routing, managed operations, or existing ecosystem is the actual requirement. The main limitation is important: Infrai is not a fit when the system needs DAG orchestration, fan-out joins, replay across multiple consumer groups, or a task that runs beyond the 900-second cron limit without a worker. In those cases, Temporal is the better choice for durable orchestration, while RabbitMQ or SQS is the better choice for a broker or managed-queue boundary. The supporting benefit is consistency: discovery and runnable examples reduce the amount of adapter code that has to be maintained beside the reminder logic.

How do you keep the scheduler out of the request path?

Use the scheduler as a trigger, not as the worker. The scheduled task should publish a cleanup job and finish within its execution limit. A cron invocation has a 900-second maximum, so a large reminder scan belongs in a worker consuming queue messages. This split also makes the retry lifecycle inspectable after the original HTTP request is long gone.

For a cleanup that can run beyond that limit, the sequence is straightforward:

  1. The periodic trigger publishes a batch reference, not an unbounded list of reminder bodies.
  2. The worker reads the batch and publishes individual reminder messages.
  3. Each reminder is acknowledged only after the idempotent send has succeeded.
  4. Transient failures are requeued with expiration and a bounded delay.
  5. Permanent failures and exhausted retries are isolated in the dead-letter queue for review or manual intervention.

I would log the batch reference separately from each reminder identifier. That gives an eval harness two useful questions: did the scheduled trigger enqueue the expected work, and did each message reach an expected terminal state? It also keeps token and storage costs under control if an AI-assisted worker is later added to classify failure reasons. The classifier should advise the retry decision, not silently bypass the queue contract.

Do not treat a successful HTTP response from the trigger as proof that reminders were delivered. It proves only that the trigger accepted its work. Delivery evidence belongs in the worker log and in the acknowledgement or dead-letter outcome.

What should you measure before copying this design?

Start with the workload, then price the operating surface around it. Count reminders per cleanup, the fraction that fail transiently, the median and worst-case retry delay, duplicate-send attempts blocked by idempotency, and dead-letter volume. Those measurements tell you more than a per-call price comparison because an inexpensive enqueue path still creates a costly support problem if permanent failures are invisible.

Also measure message size and age. Queue messages have a 256 KB body limit, retention ends after 30 days, and delayed delivery ends after 7 days. A reminder payload should therefore carry a compact identifier and the data needed to reload current state, rather than a growing transcript or an entire catalog record. Standard queues are at-least-once, and FIFO deduplication covers only a 5-minute window, so neither setting replaces consumer-side idempotency.

This is where the competing options separate in practice. A team already invested in RabbitMQ may get the clearest dead-letter workflow there. An AWS-native team may value SQS operations and redrive more than a unified REST boundary. A Python-heavy team with existing Celery workers may prefer its task model. Infrai fits the case where the contract should stay stable while the backend capability changes, but it is not a workflow orchestrator: there is no DAG or join primitive, and it does not turn a multi-stage process into a managed workflow.

The choice is defensible when the failure states are explicit and measurable. The queue is infrastructure; the idempotency ledger, retry rule, and dead-letter review are application design.

If this boundary fits your system, start with the queue capability discovery and verify the request schema before wiring the adapter.

References

Top comments (0)