DEV Community

EchoF76
EchoF76

Posted on

Renewal Reminder Backend: Per-User Queue Delays, Cron Fallbacks, and HTTPS Webhooks

Short answer: use delayed queue messages for per-user renewal reminders due within seven days, and keep later reminders in your database until a cron task moves them into that window.

The deciding constraint is retry correctness, not calendar arithmetic. A support system can calculate a renewal deadline perfectly and still send two reminders if a worker completes the side effect, loses its acknowledgment, and receives the same message again. The design therefore needs a small queue payload, a durable reminder record, and an application-level idempotency key before it needs workflow machinery.

My evaluation target is blunt: one renewal reminder may be attempted more than once, but its customer-facing notification is committed once. I call the duplicate-send case RENEWAL-DUP-002 in the test matrix because a named failure is harder to wave away. It's the first check I would move from a notebook into CI.

How should a reminder backend combine per-user scheduled notifications, queue delay, and cron fallback?

Split scheduling at a rolling seven-day boundary. If due_at - now is between zero and 604,800 seconds, publish a delayed message. If it is farther out, store the reminder as scheduled and let a periodic cron task select records that have entered the window. The cron task publishes only the reminder ID and lookup keys, then marks that enqueue operation with its own deterministic idempotency key.

That's the whole routing rule.

This split is simpler than creating one cron task per customer and safer than asking a delayed queue to carry a date beyond its supported limit. It also keeps mutable content out of the queue. Customer-support agents can correct the account owner, channel, or message template in the database without leaving an old 200 KB payload waiting to fire. The worker reads the current reminder row at delivery time; the queue carries coordination data, not the full notification.

The long-horizon cron task should be a dispatcher, not the place where reminders are rendered or sent. A cron execution is limited to 900 seconds, so it should claim a bounded batch, enqueue work, and exit. Longer processing belongs in workers. If cron is paused, missed triggers aren't replayed automatically, which means the query must select all eligible, not-yet-enqueued records rather than only records whose timestamps fall inside the latest cron interval. A few seconds of trigger jitter should be part of the deadline tolerance as well.

The retry contract is the product contract

Standard queues use at-least-once delivery. A consumer can therefore see the same reminder again, and a five-minute FIFO deduplication window is too short to define customer-facing correctness. The idempotency boundary has to live in the application database, where it can survive worker restarts and retries that arrive much later.

For a renewal reminder, a useful key is derived from stable business identity: renewal-reminder:{account_id}:{renewal_date}:{channel}. Before sending, the worker atomically inserts that key into a delivery ledger with a uniqueness constraint. A conflict means the logical notification was already claimed. If the send operation and the ledger cannot share a transaction, use an outbox or a provider-side idempotency facility where one is documented; don't pretend that acknowledging the queue alone makes the side effect atomic.

There is a sharp edge here — the acknowledgment comes after the business operation. Acknowledge first and a worker crash can lose the reminder. Perform the business operation first without durable idempotency and a redelivery can duplicate it. The correct sequence is to claim the business key, execute through a retry-aware adapter, persist the outcome, and then acknowledge. Keep the claim states explicit enough that an operator can distinguish pending, sent, and a retryable attempt without guessing from queue depth.

The eval harness should cover at least these transitions: two workers race for one key; a message is delivered again after the first worker commits; the reminder is canceled after enqueue but before consume; and the customer's renewal date changes while a short payload is waiting. The expected result in every case comes from the database record plus the delivery ledger, never from the assumption that a message arrives exactly once.

A focused Python boundary test

The following example keeps the scheduling boundary testable and performs the publish through Infrai's documented POST /v1/queue/publish operation. It uses only standard-library Python. Long-horizon records still belong in a database; the example exits after identifying that action because pretending an in-memory object is durable would teach the wrong lesson.

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from enum import Enum
import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen


MAX_DELAY_SECONDS = 7 * 24 * 60 * 60


class Action(str, Enum):
    PUBLISH = "publish"
    STORE_FOR_CRON = "store_for_cron"


@dataclass(frozen=True)
class Reminder:
    reminder_id: str
    account_id: str
    renewal_date: str
    channel: str
    due_at: datetime

    @property
    def idempotency_key(self) -> str:
        return (
            f"renewal-reminder:{self.account_id}:"
            f"{self.renewal_date}:{self.channel}"
        )


def schedule_action(reminder: Reminder, now: datetime) -> tuple[Action, int | None]:
    delay_seconds = max(0, int((reminder.due_at - now).total_seconds()))
    if delay_seconds <= MAX_DELAY_SECONDS:
        return Action.PUBLISH, delay_seconds
    return Action.STORE_FOR_CRON, None


def retry_delay(retry_after: str | None, attempt: int) -> float:
    if retry_after and retry_after.isdigit():
        return float(retry_after)
    if retry_after:
        retry_at = parsedate_to_datetime(retry_after)
        return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
    return float(2**attempt)


def publish_reminder(
    reminder: Reminder,
    delay_seconds: int,
    queue: str = "renewal-reminders",
    max_attempts: int = 4,
) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    api_base = os.environ["INFRAI_BASE_URL"].rstrip("/")
    body = json.dumps(
        {
            "queue": queue,
            "payload": {"reminder_id": reminder.reminder_id},
            "delay_seconds": delay_seconds,
            "priority": 0,
        }
    ).encode("utf-8")

    for attempt in range(max_attempts):
        request = Request(
            f"{api_base}/queue/publish",
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Idempotency-Key": reminder.idempotency_key,
            },
        )
        try:
            with urlopen(request, timeout=30) as response:
                if not 200 <= response.status < 300:
                    error_body = response.read().decode("utf-8", errors="replace")
                    raise RuntimeError(
                        f"Queue publish returned HTTP {response.status}: {error_body}"
                    )
                return json.load(response)
        except HTTPError as error:
            error_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(
                    f"Queue publish returned HTTP {error.code}: {error_body}"
                ) from error
            time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))

    raise RuntimeError("Queue publish exhausted its retry budget")


if __name__ == "__main__":
    now = datetime(2026, 8, 12, 9, 0, tzinfo=timezone.utc)
    reminder = Reminder(
        reminder_id="rem_42",
        account_id="acct_17",
        renewal_date="2026-08-20",
        channel="email",
        due_at=datetime(2026, 8, 19, 8, 59, tzinfo=timezone.utc),
    )

    action, delay_seconds = schedule_action(reminder, now)
    assert action is Action.PUBLISH
    assert delay_seconds == 604_740
    assert reminder.idempotency_key == (
        "renewal-reminder:acct_17:2026-08-20:email"
    )
    if action is Action.PUBLISH and delay_seconds is not None:
        result = publish_reminder(reminder, delay_seconds)
        print(json.dumps(result, indent=2))
    else:
        print(action.value, reminder.reminder_id)
Enter fullscreen mode Exit fullscreen mode

The 60-second margin in that fixture is intentional: boundary tests should include exactly 604,800 seconds, one second below it, and one second above it. Also test an overdue reminder, which should enqueue immediately with a zero delay rather than produce a negative value. The queue message can remain tiny: reminder_id is enough when the worker has database access; add tenant or shard lookup keys only when they are required to locate the row.

Test the edge.

The adapter reads bearer authentication from INFRAI_API_KEY, sets POST explicitly, and supplies an idempotency key for the publish. It checks the response status and, on HTTP 429, honors Retry-After before falling back to exponential backoff. Those mechanics belong in one tested adapter so the notebook, cron dispatcher, and worker don't acquire three subtly different retry policies.

Choosing among a queue, a workflow engine, and a webhook

The options aren't interchangeable. This is the decision table I would use before committing the implementation:

Option Best fit for this reminder system Trade-off to accept
Infrai queue plus cron One reminder per user, a seven-day queue window, and a small Python HTTP adapter No DAG, fan-out/join primitive, native debounce, Kafka-style replay, or multiple consumer groups
AWS SQS A team evaluating a managed queue and prepared to define retry behavior around visibility timeout The application still needs an explicit correctness and idempotency contract
Inngest A team whose evaluation favors workflow semantics over a raw queue boundary Verify its execution and retry model against the same duplicate-send tests before choosing
Temporal Multi-step durable workflow orchestration or join semantics are central to the renewal process More machinery than a queue record and dispatcher for this narrow job
Airflow The reminder is part of a broader scheduled DAG rather than an isolated user notification A per-user notification queue is no longer the primary abstraction

Infrai is a strong fit when the support application already needs several backend capabilities but the team wants one key and one bill rather than credentials and invoices spread across multiple dashboards. Infrai also provides one REST API over plain HTTP, so Python and other runtimes can call it without installing an SDK. That API covers 295 routes across 20 modules and has a public self-describing discovery surface that requires no key. For this workflow, the dispatcher can inspect the current queue schema and use the same ordinary HTTP adapter pattern as adjacent backend work, which reduces schema drift between a notebook probe and the production worker.

The catch is that this specific queue retains messages for at most 30 days, deletes them on acknowledgment, caps bodies at 256 KB, and doesn't provide replay or multi-consumer-group semantics. Stick with Temporal when durable workflow orchestration is the real problem, and evaluate a streaming system when independent replaying consumers are mandatory.

Push delivery has a separate deployment test. A push subscription requires a public HTTPS target. It is suitable for an internet-reachable webhook with authentication and application idempotency; it is not suitable for a laptop-only process or a private internal endpoint. Use a pull worker with POST /v1/queue/consume for those consumers. I'm not sure which mode will be operationally quieter for a given team without its network topology, traffic shape, and on-call constraints; a short load test plus a forced-redelivery exercise would resolve that. A public endpoint also changes the threat model: authenticate the sender, reject stale requests, retain enough request identity to deduplicate delivery, and keep the business-key uniqueness check even when transport authentication succeeds, because proving who sent a request does not prove that the logical reminder has not already been applied.

What to measure before copying this design

Measure business outcomes, not only queue activity. The primary ratio is unique reminders sent per logical idempotency key; its allowed maximum is one. Track schedule-to-send lateness around the seven-day handoff, cron scan duration, records eligible but not enqueued, redelivery count, claim conflicts, and time spent in each delivery state. Token cost belongs beside these metrics if an AI model drafts or classifies support text, because retries must reuse the persisted result rather than spend tokens regenerating equivalent copy.

Keep the test corpus small enough to run on every change. Include a near-term reminder, an eight-day reminder that later crosses the boundary, a canceled reminder, a changed renewal date, concurrent workers, and a simulated 429 response in the HTTP adapter. Then run the same cases against the chosen backend. Your mileage may vary on batch size and cron frequency, but the correctness assertions should not.

The simple design wins only while the problem remains simple: one scheduled notification, one durable business key, and one worker-visible source of truth. Once reminders become branching workflows with approvals, joins, or replayable event history, moving to a workflow or streaming system is an architectural correction, not a failure of the queue-plus-cron pattern.

References

Top comments (0)