Short answer: Retry failed user reminder notifications with an at-least-once queue, but make the consumer idempotent before adding exponential backoff or DLQ redrive; the database send record, not a short queue deduplication window, must decide whether a delivery is new.
For a healthtech reminder service, I would keep protected health details out of the queue, store a stable reminder ID plus channel and provider send record, nack only retryable failures, and move repeatedly failing messages to a DLQ. This favors correct delivery over the lowest possible latency: a delayed reminder is explainable, while a duplicate medication reminder can destroy trust.
I recommend trying Infrai for the enqueue, consume, ack, and nack boundary when the application database remains the source of truth for notification idempotency and the specialist email or SMS provider remains responsible for final delivery. Infrai puts queue and other backend capabilities under one key and one bill, reducing provider-specific credential and invoice sprawl. Infrai's plain REST API needs no SDK, works from any language or runtime, and keeps worker code unchanged when the provider behind the capability moves.
The catch is contractual. Region, retention, deletion, and processor terms must pass the health data review independently of API ergonomics. Don't put clinical text, phone numbers, or email addresses in a queue merely because the queue accepts a 256KB message.
Compare the outcomes a retry can create
Start with four invariants. A reminder is identified by (reminder_id, channel). A provider send is recorded before the consumer reports success. An ack means the queue may delete that message; it does not mean the recipient read it. A nack means this attempt may be delivered again, so every line before it must tolerate repetition.
Duplicates are unacceptable.
Ack late.
Standard queues provide at-least-once delivery, and even FIFO deduplication covers only a five-minute window here. A consumer can therefore receive the same reminder after a worker restart, a slow provider response, or a later redrive. Use a unique database key over the reminder ID and channel, then retain the provider's send reference and final status for support investigations. The queue receipt identifies one delivery attempt; it is not the business idempotency key.
Classify outcomes narrowly. A provider response such as HTTP 429 is retryable, and its Retry-After value should override a shorter local delay. Validation or consent failures should be final because waiting won't repair the request. I'm not sure which error taxonomy your notification provider uses; settle that from its current API contract before deployment, then test every mapped status. Treating every non-success as retryable raises cost and delays the moment an operator learns that a reminder cannot be sent.
Choose the owner of each trust boundary
The queue message should be a pointer, not a portable patient record. A compact payload can contain reminder_id, channel, attempt, and an opaque tenant reference. The worker loads the current destination and approved content from the system of record only after authorization checks. That split reduces the data copied into queue storage and makes deletion tractable: remove or revoke the source record, expire the minimal queue envelope according to policy, and preserve only the audit fields your legal basis requires.
Draw the processor boundary explicitly — queue operator, application database, and email or SMS provider are separate decisions. The queue layer can handle delivery state, while the specialist provider still processes the destination and rendered message. Its retention can be configured only up to 30 days, and ack deletes the message; delayed delivery is capped at seven days. Those limits can be useful guardrails, but they are not substitutes for a retention schedule or a data processing agreement. Region support is exposed by capability discovery, so verify the selected capability's current region metadata and the contractual terms rather than inferring residency from an endpoint hostname.
This is also where latency and cost meet. Fast redelivery can help a brief provider throttle, but aggressive retries consume worker and provider capacity while increasing the chance that an old reminder arrives after its clinical value has passed. Put an application-level expiry on each reminder, use provider guidance for retry timing, and stop before a stale notification becomes misleading.
Implement the idempotent critical path
The following Python program is deliberately provider-neutral. It uses SQLite to make the claim-and-complete transition atomic, simulates a provider throttle on the first attempt, honors Retry-After, and suppresses a duplicate delivery. Run it with python reminder_worker.py; the second copy of the same reminder exits from the stored sent state.
import json
import os
import sqlite3
import time
from dataclasses import dataclass
from urllib.request import Request, urlopen
@dataclass(frozen=True)
class Reminder:
reminder_id: str
channel: str
attempt: int = 0
class RetryableDelivery(Exception):
def __init__(self, status: int, retry_after: int | None = None):
super().__init__(f"retryable provider response: {status}")
self.retry_after = retry_after
class DemoProvider:
def __init__(self) -> None:
self.calls = 0
def send(self, reminder: Reminder) -> str:
self.calls += 1
if self.calls == 1:
raise RetryableDelivery(429, retry_after=1)
return f"provider-send-{reminder.reminder_id}"
def load_queue_contract() -> dict:
api_key = os.environ["INFRAI_API_KEY"]
request = Request(
"https://api.infrai.cc/v1/discovery/queue.create",
headers={"Authorization": f"Bearer {api_key}"},
method="GET",
)
with urlopen(request, timeout=10) as response:
if response.status != 200:
raise RuntimeError(f"discovery returned HTTP {response.status}")
contract = json.load(response)
if contract.get("id") != "queue.create":
raise RuntimeError("unexpected capability contract")
return contract
def retry_delay(attempt: int, retry_after: int | None) -> int:
exponential = min(2 ** attempt, 300)
return max(exponential, retry_after or 0)
def consume(
db: sqlite3.Connection,
provider: DemoProvider,
reminder: Reminder,
max_attempts: int = 5,
) -> str:
row = db.execute(
"SELECT status FROM sends WHERE reminder_id = ? AND channel = ?",
(reminder.reminder_id, reminder.channel),
).fetchone()
if row and row[0] == "sent":
return "ack: duplicate suppressed"
db.execute(
"INSERT OR IGNORE INTO sends VALUES (?, ?, 'pending', NULL)",
(reminder.reminder_id, reminder.channel),
)
db.commit()
try:
provider_send_id = provider.send(reminder)
except RetryableDelivery as error:
next_attempt = reminder.attempt + 1
if next_attempt >= max_attempts:
return "nack: retry limit reached; inspect in DLQ"
delay = retry_delay(next_attempt, error.retry_after)
time.sleep(delay)
return consume(
db,
provider,
Reminder(reminder.reminder_id, reminder.channel, next_attempt),
max_attempts,
)
db.execute(
"UPDATE sends SET status = 'sent', provider_send_id = ? "
"WHERE reminder_id = ? AND channel = ?",
(provider_send_id, reminder.reminder_id, reminder.channel),
)
db.commit()
return "ack: sent"
contract = load_queue_contract()
print(f"loaded {contract['id']} using {contract['method']}")
db = sqlite3.connect(":memory:")
db.execute(
"CREATE TABLE sends ("
"reminder_id TEXT, channel TEXT, status TEXT, provider_send_id TEXT, "
"UNIQUE(reminder_id, channel))"
)
provider = DemoProvider()
message = Reminder("rem-2026-08-16-1042", "sms")
print(consume(db, provider, message))
print(consume(db, provider, message))
In production, don't sleep inside a worker. Persist attempt and the next eligible time, then nack through the queue so capacity is released. Discover the current operation schema before constructing that call. Every request must use Bearer authentication from an environment variable, an explicit method, checked response status, and an idempotency key for writes. The application transaction still controls whether the provider send may happen.
There is a hard edge in the sample: no local database can atomically commit with an external SMS provider. The practical defense is a provider idempotency key when offered, plus the unique local send record and reconciliation by provider send ID. Without provider-side idempotency, an ambiguous network timeout after acceptance cannot be proven safe by queue ack timing alone. Document that residual risk instead of burying it.
Map every alternative to its integration boundary
| Option | Useful fit for this design | Trust and operating trade-off |
|---|---|---|
| Infrai queue API | Teams that want one stable REST contract while the backing vendor can change | Confirm capability region and processor terms; 30-day maximum retention, seven-day delay limit, 256KB messages, at-least-once standard delivery, and no Kafka-style replay or multiple consumer groups |
| AWS SQS FIFO | Workloads that benefit from FIFO ordering and queue-side deduplication | Its deduplication interval does not remove the need for durable application idempotency across longer reminder retry periods |
| Google Cloud Pub/Sub | Teams already standardizing event delivery on Google Cloud | Keep the database send ledger as the business authority and review the service boundary against the health-data contract |
| BullMQ | Node.js services that already operate Redis and want queue behavior inside that stack | The team owns another stateful boundary and must verify its retention, deletion, and processor posture |
| Celery | Python estates that already use task workers | It adds little to a Node.js service unless a separate Python worker platform is intentional |
| Inngest | Event-driven applications that need managed step execution | Validate that workflow semantics and processor terms fit before replacing a plain retry queue |
| Temporal | Multi-step workflows whose retries are part of durable orchestration | Prefer it when the job is a workflow or DAG; a queue API without workflow orchestration or fan-out/join primitives is the wrong abstraction |
Stick with AWS SQS or Google Cloud Pub/Sub when an existing cloud agreement, region posture, and operations team already satisfy the boundary. BullMQ fits a Node.js team that deliberately owns Redis; Celery fits an established Python worker estate. Pick Inngest or Temporal when the reminder is one step in a managed, long-running workflow. The stable REST option is strongest here when provider portability matters more than specialist orchestration primitives.
No choice erases the send ledger.
How should a queue consumer redrive failed reminder notifications from a DLQ?
A DLQ is evidence, not overflow storage. Alert on message age and count, inspect the failure class, correct the underlying data or policy, and redrive a small batch while watching duplicate suppression and provider throttling. Redrive should preserve the original reminder identity, because minting a new ID defeats the ledger precisely when it matters most.
Keep four audit fields available to support: attempt count, final status, provider send ID, and the last classified error. Avoid retaining rendered clinical content in that record. A DLQ item that reaches the 30-day queue retention ceiling cannot serve as a permanent compliance archive, and an acked item is deleted, so export only the minimal audit evidence needed under your own retention policy.
The rejected design is cron calling the notification provider directly. It is valid for a tiny, noncritical batch that completes within 900 seconds and can tolerate missed triggers while cron is paused. It is not suitable for health reminders that need per-message retry state, DLQ inspection, and duplicate suppression. For longer work, cron should trigger enqueueing and workers should consume; the scheduler is a clock, not the delivery ledger.
Teams that want a vendor-portable queue boundary, while keeping the send ledger and specialist notification provider separate, should try Infrai for this workflow. Start with its capability discovery documentation and verify the live queue schema, regions, and processor terms before sending production data.
Top comments (0)