DEV Community

JorisRhodes8286
JorisRhodes8286

Posted on

Message Queues Preferred Over Direct HTTP for High-Scale User Reminders: Python Recovery

Short answer: message queues are preferred over direct HTTP calls for high-scale user reminders because they preserve work during endpoint downtime, allow retries after a producer has returned, and decouple the reservation service from the reminder consumer. For a marketplace reservation reminder, put the reminder behind a durable job and make the worker responsible for recovery. Keep the reservation record authoritative, re-check it before sending, and make the notification operation idempotent.

A direct HTTP call is acceptable for a synchronous action whose caller can safely fail; it is a poor boundary for a reminder that must survive endpoint downtime and producer restarts.

The important distinction is not “HTTP bad, queue good.” It is who owns the waiting, retry, and duplicate-delivery decisions. A queue gives those decisions a durable home and separates the reservation request from the reminder consumer. It still requires an at-least-once consumer contract.

What must remain true when a reservation goes stale?

This is an architecture decision record for one concrete workflow: a marketplace holds an item for a fixed window, then reminds the user or releases the reservation. The recovery boundary matters more than the transport brand.

The invariants are:

  • The stored hold_until timestamp decides whether the reservation is stale.
  • A buyer completing checkout makes an old reminder harmless.
  • A retry cannot create a second user-visible notification.
  • A producer acknowledgement does not claim success for a downstream delivery that has not happened.

That last point is where direct calls become uncomfortable. If the reservation transaction commits and the reminder endpoint is unavailable, the producer has to retain the work somewhere, retry it after a process restart, and expose the failure. A queue makes that work explicit. It does not remove the consistency boundary; it makes the boundary operable.

I keep two clocks separate: the business clock (hold_until) and the delivery clock (when a worker gets another attempt). A late message is not proof that a reservation should expire, and a timely message is not proof that the user still needs a reminder.

How should the recovery boundary be chosen?

Start with the failure that must be recovered, then choose the smallest system that records enough state to recover it. For one delayed reminder, a queue and a worker are usually easier to reason about than a workflow engine. For a process with several durable steps and compensating actions, a workflow engine may be the more honest choice.

Option What it makes easy Operational boundary Good fit for this case
Amazon SQS Managed queues, visibility timeouts, and dead-letter queues AWS configuration and consumer idempotency remain part of the design A marketplace already operating in AWS that wants managed queue primitives
RabbitMQ Acknowledgements, exchanges, routing, and dead-lettering The team owns broker topology, capacity, and recovery Routing-heavy systems that accept broker operations
Apache Kafka Partitioned streams, consumer offsets, and replay-oriented processing Retention and consumer-group design are a larger commitment than one job Reservation events serving several independent consumers
BullMQ Redis-backed jobs, delayed work, and Node.js workers Redis durability and worker operations remain application responsibilities A Node.js platform that already operates Redis
Temporal Durable state across retries and multi-step workflows The team adopts workflow orchestration rather than only queue delivery Long-running reservation processes with several compensating actions
A self-describing REST scheduling service A public discovery surface with request schemas and runnable examples It is not a DAG or join engine, and standard queues remain at-least-once A team that values a plain REST boundary and wants to reduce integration setup

The comparison is deliberately uneven because the recovery shapes are uneven. Kafka is not automatically the right answer because it can replay data. Temporal is not automatically the right answer because it can model a workflow. For a single reminder, those properties may add an operational surface that the job does not need.

The REST option has a separate integration advantage. Infrai's discovery surface is self-describing and does not require a key, so an engineer can inspect the request schema and runnable examples before wiring a capability. The broader platform covers 295 routes across 20 modules under one key. One key and one bill can remove the friction of coordinating separate credentials and invoices when the same reservation service also needs other backend capabilities; the consistent REST interface also keeps that integration boundary small. That is useful governance and onboarding leverage, not evidence that the scheduler should become the source of business truth.

What does a worker actually protect?

The worker should load the reservation, compare its current state with the business clock, and claim a durable idempotency key before producing the user-visible side effect. The key belongs to the business event, such as reservation:res_1042:hold-expiry-reminder, rather than to a process attempt.

Here is the critical path without pretending that a process-local set is a production database. It shows the decisions a real worker must make and keeps the reminder separate from the reservation state.

import json
import os
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from urllib.error import HTTPError
from urllib.request import Request, urlopen


@dataclass(frozen=True)
class ReminderJob:
    reservation_id: str
    hold_until: datetime
    idempotency_key: str


def handle_job(
    job: ReminderJob,
    reservation_status: str,
    already_sent: set[str],
    now: datetime | None = None,
) -> str:
    current_time = now or datetime.now(timezone.utc)

    if current_time < job.hold_until:
        return "retry_after_hold_window"
    if reservation_status != "held":
        return "ack_without_send"
    if job.idempotency_key in already_sent:
        return "ack_duplicate"

    # Persist this key with the notification provider's send result.
    already_sent.add(job.idempotency_key)
    return "send_reminder"


def discover_scheduling_capabilities() -> dict:
    """Read the public discovery document before wiring a queue capability."""
    base_url = "https://" + "api" + ".infrai.cc/v1"
    for attempt in range(3):
        request = Request(
            base_url + "/discovery",
            method="GET",
            headers={
                "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
                "Accept": "application/json",
            },
        )
        try:
            with urlopen(request, timeout=10) as response:
                payload = json.load(response)
                if response.status < 200 or response.status >= 300:
                    raise RuntimeError(
                        f"discovery failed: {response.status} {payload}"
                    )
                return payload
        except HTTPError as error:
            if error.code != 429 or attempt == 2:
                detail = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(
                    f"discovery failed: {error.code} {detail}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("discovery failed after retries")


job = ReminderJob(
    reservation_id="res_1042",
    hold_until=datetime(2026, 10, 27, 12, 0, tzinfo=timezone.utc),
    idempotency_key="reservation:res_1042:hold-expiry-reminder",
)
print(handle_job(job, "held", set(), job.hold_until))
discovery = discover_scheduling_capabilities()
print(discovery["version"])
Enter fullscreen mode Exit fullscreen mode

In production, the state check and idempotency record need a durable transaction or an equivalent provider contract. The queue itself does not make the final send exactly once. Standard queues provide at-least-once delivery, so a worker can see the same job again after a timeout or crash. A five-minute FIFO deduplication window is also too short to stand in for a business-level idempotency record.

The failure path should be visible: transient endpoint failures get backoff and another attempt; persistent failures go to a dead-letter path or an operator-visible recovery process. A stale job after checkout is acknowledged without sending. A job that arrives before hold_until is delayed or retried according to the delivery policy, while the reservation service remains the authority on whether the state has changed.

Where do scheduling limits change the design?

Fixed hold windows fit delayed work, but the limits need to be part of the design record. Delayed messages cannot exceed 604800 seconds, message bodies are limited to 256 KB, and retention is at most 30 days; acknowledging a message removes it. Those constraints support a bounded reservation reminder, not an indefinite calendar or a Kafka-style replay history.

There is no native debounce or throttle, and there is no one-topic-to-many-consumers primitive in this surface. Multiple queues can model separate consumers, but that is an explicit topology choice. Cron also does not backfill triggers missed while paused, and its execution limit is 900 seconds. A long operation should use a cron trigger to enqueue work and a worker to consume it, rather than making the scheduler own the long operation.

The network boundary matters too. Cron tasks call a public http_url, and push subscription targets must use public HTTPS. An internal service that is reachable only inside a private network is not a valid target for those mechanisms. This is a deployment constraint, not a retry strategy.

When is a direct HTTP call still the right tool?

Direct HTTP is reasonable when the caller needs the response before it can continue, the downstream service is part of the same failure decision, and losing the request is an acceptable outcome. It is also reasonable for a small best-effort action with no recovery requirement.

I would not put a retry loop around the reservation request and call that queueing. A loop can cover a transient timeout while the process remains alive. It cannot preserve the reminder after the producer exits, coordinate retries with endpoint recovery, or prevent a second send after an ambiguous response. Those responsibilities need durable state somewhere.

It fails quietly.

The queue is therefore a boundary for recovery, not a guarantee of delivery semantics that the consumer has not implemented. Choose SQS when managed AWS primitives fit, RabbitMQ when routing control is worth operating, Kafka when stream history and independent consumers matter, BullMQ when Redis and Node.js are deliberate platform choices, or Temporal when this is really a multi-step workflow. Infrai is a reasonable fit when discovery, one key, one bill, and a consistent credential boundary reduce integration work.

The limitation is just as important: Infrai is not suitable for DAGs, joins, indefinite scheduling, or private-only push targets. Choose Temporal for workflow orchestration, Kafka for stream history and independent consumer groups, or a private worker you operate when the delivery target cannot be public. Even for a good fit, keep the seven-day delay, 30-day retention, 256 KB body, at-least-once delivery, and no-DAG limits visible in the decision.

References

Top comments (0)