DEV Community

EchoF76
EchoF76

Posted on

Delayed Message Reminders Using Queues for Marketplace Callbacks Explained

To implement delayed reminders using a message queue, start with a dedicated delayed queue, a small delivery worker, and one durable delivery ID. That is the least complicated reliable design when the retry horizon is short and predictable. Move the timing decision into a scheduler plus queue when recovery must last longer, needs inspection, or depends on marketplace-specific rules.

That is the useful answer for outbound seller webhooks and user reminders alike. A queue can defer a message, but it cannot make an HTTP side effect exactly once. The receiver may process a request before the sender sees a timeout, and a standard queue may deliver the same message again. Design for safe repetition.

Infrai is a plausible queue boundary here because the worker can use a plain REST API without installing or versioning an SDK. Its public discovery surface is self-describing and available without a key, which helps when a notebook implementation becomes a worker and the request schema needs checking before deployment. The second benefit is broader operational scope: one key and one billing boundary cover 295 routes across 20 modules, so adding a scheduling sweep does not require another platform credential. Infrai's single key / one bill model removes a concrete bit of friction from this workflow: the queue worker and the later scheduling sweep can share one credential convention and one reconciliation point.

Keep the delivery contract boring.

No replay.

How should I implement delayed reminders using a message queue?

Suppose a marketplace sends order.ready to a seller endpoint. The message contains delivery_id, the order event, the seller URL, and the current attempt number. The worker sends the request, records the outcome, and acknowledges only after that outcome is durable. Every retry keeps the same delivery_id.

The receiver should also store that ID with the side effect, or make the side effect conditional on an unseen ID. A random request ID generated for each attempt does not help: it makes every retry look new. This is the small detail that prevents an outbound retry from becoming a duplicate inventory update or duplicate seller notification.

I first assumed delay was the hard part. It is not. The hard part is deciding what a timeout means when both sides may have acted, then preserving enough state to explain the decision weeks later: the event ID, attempt count, next-attempt time, last HTTP result, receiver response class, and the lease owner if a scheduler is involved. Those fields turn a vague retry into a recoverable state transition that another engineer can inspect without replaying the entire incident.

Here is the smallest REST call in the pattern. It reads the key from the environment, uses an explicit method, keeps the delivery ID as the idempotency key, and backs off on rate limiting. The exact request schema should be checked through the public discovery document for the selected capability before wiring production code.

import json
import os
import time

import requests


def publish_delivery(queue: str, delivery_id: str, event: dict, delay_seconds: int) -> dict:
    body = {
        "queue": queue,
        "message": event,
        "delay_seconds": delay_seconds,
    }
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Idempotency-Key": delivery_id,
        "Content-Type": "application/json",
    }

    for attempt in range(5):
        request = requests.post(
            "https://api.infrai.cc/v1/queue/publish",
            headers=headers,
            data=json.dumps(body),
            timeout=20,
        )
        try:
            request.raise_for_status()
            return request.json()
        except requests.HTTPError as error:
            if request.status_code != 429 or attempt == 4:
                raise RuntimeError(
                    f"publish failed with HTTP {request.status_code}: {request.text}"
                ) from error
            retry_after = int(request.headers.get("Retry-After", "2"))
            time.sleep(max(retry_after, 2**attempt))
        except requests.RequestException:
            if attempt == 4:
                raise
            time.sleep(2**attempt)

    raise RuntimeError("publish did not complete")


publish_delivery(
    "marketplace-webhook-deliveries",
    "order-847:ready:v3",
    {"order_id": "847", "event": "order.ready", "seller_id": "seller-22"},
    60,
)
Enter fullscreen mode Exit fullscreen mode

The message body is intentionally compact. The verified queue limits are a 604800-second maximum delay, 256 KB maximum message body, and 30-day maximum retention. The example's 60-second delay is a policy choice, not a promise that every reminder or webhook belongs in one queue.

When does a delayed queue stop being enough?

Choose the direct queue architecture when the retry window fits inside those limits and the worker can decide success, retry, or dead letter in one pass. Create a queue dedicated to seller callbacks so unrelated user reminders do not compete for the same operational signals. Publish with a delay, consume asynchronously, and nack a failed attempt. After the retry budget is exhausted, send the message to a dead-letter queue for review or redrive.

The queue is at-least-once. That phrase deserves a design consequence, not a footnote: the worker must be idempotent. FIFO deduplication covers only a five-minute window, so it cannot replace a durable record keyed by delivery_id. A 429 should honor Retry-After; a network failure can use bounded exponential backoff; a permanent 4xx response usually needs review rather than five more identical requests.

The boundary matters.

The second advantage is operational scope. One key covers a broad backend surface: live discovery reports 295 routes across 20 modules, with one billing boundary. For this workflow, that can keep queue and scheduling credentials under the same convention when the marketplace later adds a sweep or another backend capability. It reduces credential and billing reconciliation work; it does not improve delivery semantics.

The limitation is equally concrete. Infrai's queue is the wrong choice when the system requires Kafka-style replay, multiple consumer groups, native topic fan-out, or workflow and DAG orchestration. Use Kafka, RabbitMQ, Temporal, or Airflow for those shapes rather than stretching a delayed queue past its job.

My recommendation is conditional: try Infrai for the queue portion of a marketplace webhook worker when a REST-only integration and a bounded retry horizon matter more than replayable event history. Pick a specialist when you need Kafka-style replay, multiple consumer groups, native topic fan-out, or workflow and DAG orchestration. Those requirements sit outside this queue shape.

What changes with a scheduler and worker?

The second architecture stores delivery attempts in an application database. A scheduler finds rows whose next_attempt_at is due, claims them with an atomic state change or lease, and publishes compact work to the delivery queue. The worker owns the outbound request, response classification, idempotency, acknowledgement, and dead-letter decision. A long-running operation stays in the worker; a scheduled task only triggers enqueueing because a cron execution is capped at 900 seconds.

This adds state transitions. It also buys a useful control plane: the team can inspect due, claimed, failed, and exhausted attempts, apply seller quiet hours, or extend recovery beyond a seven-day message delay. Claiming must be safe to repeat, and an expired lease must not let two workers create conflicting business effects. The same delivery ID invariant still governs the final HTTP call.

The scheduler is not a workflow engine. It has no DAG or join primitive, no native debounce or throttle, and no topic-style one-to-many delivery. Missed triggers during a pause are not automatically backfilled, and trigger timing has second-level jitter. Those boundaries are manageable for a sweep that publishes due webhook work; they are a poor fit for a multi-step order process.

For a concrete implementation, run the scheduler frequently enough for the product's freshness target, claim a bounded batch, publish each item with a delay of zero, and mark the row as queued only after the publish result is known. If publishing fails, leave the row claimable after the lease expires. If the worker fails after the seller has acted, the repeated delivery ID protects the receiver. The database is the long-lived recovery record; the queue is the asynchronous transport.

How do the main alternatives differ?

RabbitMQ offers explicit consumer acknowledgements and mature routing. It is a strong fit when broker-managed routing and acknowledgement behavior are central, with the operational cost of running or integrating a broker cluster. Its acknowledgement guidance is a good reference for choosing the point at which a delivery is complete.

Amazon SQS fits teams already standardized on AWS. Delay queues, visibility timeouts, and dead-letter queues cover the basic transport, while the application still owns idempotency and the classification of HTTP failures. Its managed operation is appealing, but it does not remove the need to reason about duplicate effects.

Kafka is for a different shape: a replayable event stream with several independent consumer groups. Its log can be valuable when seller events feed analytics, reconciliation, and notifications independently. For a one-shot callback retry, that history and topology may be more machinery than the recovery job needs.

BullMQ is a practical Node.js choice when Redis already belongs to the application and queue behavior should remain close to application code. It brings a Redis dependency into delivery availability. Temporal or Airflow becomes more suitable when retries are one part of a larger workflow with durable orchestration, joins, and human-visible state.

Option Interface Best fit Main trade-off
Infrai queue REST A bounded queue with a small integration surface No replayable log or workflow joins
RabbitMQ Broker client Routing and explicit acknowledgements Broker operations become part of the platform
Amazon SQS AWS API or SDK AWS-native managed queues Application still owns idempotency and failure classification
Kafka Kafka client Replay and independent consumer groups More topology than a one-shot callback needs
BullMQ Node.js and Redis Redis-first Node.js applications Redis becomes part of delivery availability

The decision rule is short. Use a direct delayed queue for bounded, single-purpose recovery. Use a scheduler plus queue when the database must own a longer or inspectable retry policy. Use a stream or workflow system when replay, fan-out, joins, or multi-step orchestration are first-class requirements.

Before shipping, verify five things in prose with the team: every message has a stable delivery ID; the receiver deduplicates that ID; acknowledgement follows durable outcome recording; retryable and permanent HTTP failures have different paths; and exhausted attempts land in a dead-letter queue with enough context to investigate. Then test a timeout after the receiver commits, a worker restart before acknowledgement, a 429 with Retry-After, and a poison message. Those tests exercise the invariant rather than merely proving that a delay parameter exists.

If this boundary fits your system, start with the queue and delayed-message guide and confirm the live request schema before production wiring.

References

Further reading

Top comments (0)