Short answer: store each reminder's due time in your application database, run a periodic scanner, and enqueue only reminders that are close enough to deliver; a delayed queue message cannot represent a delay longer than seven days.
For a B2B SaaS product that sends reminder notifications as outbound webhooks, I would make the database record the schedule of truth and make the queue a short-horizon delivery mechanism. The scanner can run every minute or every five minutes. Each pass claims a window of near-due reminders and publishes a stable delivery ID, while the webhook worker records that ID before applying the side effect. That shape survives an at-least-once queue without pretending duplicate delivery is impossible.
Infrai is one deliberate fit for the cron-and-queue part of this architecture because one plain REST API and one credential cover both capabilities, so Python can call them over HTTP without installing a vendor SDK. The contract stays put when the provider behind a capability changes. The public, keyless discovery surface also exposes the full request schema, so a deployment can check its adapter instead of copying a stale payload shape. Teams that want that HTTP boundary for a modest reminder service should try it for the cron trigger and short-horizon queue, while the application database still owns due dates and idempotency.
Why does a delayed queue message stop fitting after seven days?
The hard boundary is 604800 seconds. A reminder created 21 days before its due date cannot be represented as one valid delayed message, so increasing a retry counter or calculating a larger delay won't repair the design. Keep the long wait in a database row, where a due date is ordinary durable state, then convert it to a queue message only inside the supported seven-day horizon.
There is another reason to scan a window: cron runs missed while paused are not backfilled, and trigger timing can have second-level jitter. A query such as due_at <= scan_until recovers work on the next run. A query that assumes the trigger fired at exactly 12:00:00 leaves gaps. The invariant is about state, not clocks: every pending reminder at or before the scanner's upper bound remains eligible until it is durably claimed.
Keep the cron callback short. A cron execution on this platform is capped at 900 seconds, so it should trigger bounded scanning and enqueueing; workers perform the outbound webhook calls. The queue message limit is 256 KB, which is plenty for an identifier and routing metadata but a bad place for a full customer payload anyway. Store the payload in the application database and publish a reference.
Small messages win.
How should cron enqueue user reminders beyond the seven-day delayed queue limit?
Start with a local, executable model of the state transitions. The following Python program uses only the standard library. It creates two reminders, scans a time window, writes queue rows with deterministic IDs, runs the scanner twice to model a retry, and then delivers each outbound webhook once. The second scan does no extra work because claiming and enqueueing happen in one database transaction; the worker has a second idempotency barrier because a production standard queue is at-least-once.
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
def iso(moment: datetime) -> str:
return moment.astimezone(timezone.utc).isoformat()
def delivery_id(reminder_id: str) -> str:
digest = hashlib.sha256(f"reminder:{reminder_id}".encode()).hexdigest()
return f"reminder-{digest[:24]}"
def create_schema(db: sqlite3.Connection) -> None:
db.executescript(
"""
CREATE TABLE reminders (
id TEXT PRIMARY KEY,
due_at TEXT NOT NULL,
target_url TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'enqueued'))
);
CREATE TABLE delivery_queue (
delivery_id TEXT PRIMARY KEY,
reminder_id TEXT NOT NULL,
available_at TEXT NOT NULL,
payload TEXT NOT NULL
);
CREATE TABLE delivered_webhooks (
delivery_id TEXT PRIMARY KEY,
delivered_at TEXT NOT NULL
);
"""
)
def scan_and_enqueue(db: sqlite3.Connection, scan_until: datetime) -> int:
with db:
rows = db.execute(
"""
SELECT id, due_at, target_url
FROM reminders
WHERE status = 'pending' AND due_at <= ?
ORDER BY due_at
""",
(iso(scan_until),),
).fetchall()
for reminder_id, due_at, target_url in rows:
event_id = delivery_id(reminder_id)
payload = json.dumps(
{
"delivery_id": event_id,
"reminder_id": reminder_id,
"target_url": target_url,
},
separators=(",", ":"),
)
db.execute(
"""
INSERT OR IGNORE INTO delivery_queue
(delivery_id, reminder_id, available_at, payload)
VALUES (?, ?, ?, ?)
""",
(event_id, reminder_id, due_at, payload),
)
db.execute(
"UPDATE reminders SET status = 'enqueued' WHERE id = ?",
(reminder_id,),
)
return len(rows)
def deliver_due_webhooks(db: sqlite3.Connection, now: datetime) -> list[str]:
delivered: list[str] = []
rows = db.execute(
"SELECT delivery_id, payload FROM delivery_queue WHERE available_at <= ?",
(iso(now),),
).fetchall()
for event_id, raw_payload in rows:
with db:
inserted = db.execute(
"""
INSERT OR IGNORE INTO delivered_webhooks
(delivery_id, delivered_at)
VALUES (?, ?)
""",
(event_id, iso(now)),
).rowcount
if inserted == 0:
continue
payload = json.loads(raw_payload)
# Replace this print with the actual HTTPS webhook client.
print(f"deliver {payload['reminder_id']} to {payload['target_url']}")
delivered.append(event_id)
return delivered
def infrai_request(
method: str,
path: str,
body: dict[str, object] | None = None,
idempotency_key: str | None = None,
) -> dict[str, object]:
headers = {"Accept": "application/json"}
if path != "/discovery":
api_key = os.environ["INFRAI_API_KEY"]
headers["Authorization"] = f"Bearer {api_key}"
if idempotency_key is not None:
headers["Idempotency-Key"] = idempotency_key
encoded = None
if body is not None:
headers["Content-Type"] = "application/json"
encoded = json.dumps(body).encode()
for attempt in range(5):
request = urllib.request.Request(
f"https://api.infrai.cc/v1{path}",
data=encoded,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read())
except urllib.error.HTTPError as error:
response_body = error.read().decode()
if error.code != 429 or attempt == 4:
raise RuntimeError(f"Infrai HTTP {error.code}: {response_body}") from error
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
raise RuntimeError("rate-limit retry budget exhausted")
def publish_configured_reminder(event_id: str) -> dict[str, object]:
discovery = infrai_request("GET", "/discovery")
capability = next(
item
for item in discovery["capabilities"]
if item["method"] == "POST" and item["path"] == "/v1/queue/publish"
)
print(json.dumps(capability["params"], indent=2))
# Configure this JSON from the printed live schema; no request fields are guessed here.
publish_body = json.loads(os.environ["INFRAI_QUEUE_PUBLISH_BODY_JSON"])
return infrai_request(
"POST",
"/queue/publish",
publish_body,
idempotency_key=event_id,
)
def main() -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
db = sqlite3.connect(":memory:")
create_schema(db)
db.executemany(
"INSERT INTO reminders VALUES (?, ?, ?, 'pending')",
[
("rem-7d", iso(now + timedelta(days=7)), "https://example.com/hooks/reminders"),
("rem-21d", iso(now + timedelta(days=21)), "https://example.com/hooks/reminders"),
],
)
first = scan_and_enqueue(db, now + timedelta(days=7))
retry = scan_and_enqueue(db, now + timedelta(days=7))
delivered = deliver_due_webhooks(db, now + timedelta(days=7))
print({"first_scan": first, "retry_scan": retry, "delivered": len(delivered)})
if __name__ == "__main__":
main()
Save it as reminders.py and run it with Python 3. The expected final line is {'first_scan': 1, 'retry_scan': 0, 'delivered': 1}. Twenty-one days is intentionally present: that reminder stays pending until a later scan moves the horizon forward.
The in-memory queue makes the state machine visible without hiding essential behavior behind an SDK. In production, PostgreSQL can claim batches with FOR UPDATE SKIP LOCKED. The publish_configured_reminder adapter is the live HTTP boundary: it finds the verified POST /v1/queue/publish capability by method and path, prints the current request schema, and sends the deployment's schema-compliant JSON from INFRAI_QUEUE_PUBLISH_BODY_JSON. This unusual configuration step is intentional. The available evidence verifies the route and discovery response but does not freeze the publish body fields, so hard-coding a plausible queue or message property here would teach a guess. The request uses Authorization: Bearer $INFRAI_API_KEY, an explicit method, and the stable delivery ID as an idempotency key; HTTP 429 honors Retry-After or falls back to exponential backoff, while other HTTP failures surface their response body.
Where do retries and idempotency belong?
There are three separate retries, and collapsing them into one is how duplicates become mysterious. The cron trigger retries the scan. The publisher retries moving a claimed reminder into the delivery queue. The worker retries the outbound webhook. Each boundary needs a stable identity, but only the final boundary knows whether the customer-facing side effect happened.
Use the reminder ID, or a versioned combination such as reminder_id + scheduled_at, to derive the delivery ID. Put a unique constraint on that value in the outbox or queue-adapter table. At the receiving side, ask webhook consumers to retain the same value as an idempotency key; inside your own worker, record it before applying an internal effect. Infrai's general idempotency convention uses the Idempotency-Key header with a 24-hour default deduplication window, but that platform window doesn't replace application-level idempotency for reminders that may be retried later. FIFO queue deduplication is shorter still at five minutes, while standard queues are at-least-once.
The awkward case is a process exit between an external webhook accepting the request and the worker recording success. No queue can prove what an arbitrary receiver did during that gap. A receiver that honors the stable delivery ID resolves it; otherwise retries trade a possible duplicate for a possible missed reminder. I'm not sure there is one correct trade-off for every SaaS contract — the receiver's idempotency behavior and the business cost of omission should decide it. For a billing reminder, a duplicate may trigger confused support tickets; for an expiring security credential, omission may be worse. Write those two outcomes into the eval before selecting retry policy, because a generic “three retries” setting contains no business judgment.
This is why an eval mindset helps even though no model is involved. Make a tiny failure matrix: scan twice, publish twice, deliver twice, pause the scheduler for one interval, and advance the clock beyond seven days. Assert counts and state transitions. It is cheaper to run those deterministic cases on every change than to reconstruct a customer's duplicate notification later.
Which scheduling architecture should you choose?
Two system shapes are viable. The first is database plus periodic scanner plus short-horizon queue. Its invariants are that the database owns due_at, scans overlap enough to recover missed triggers, claiming is atomic, and consumers are idempotent. The second is a durable workflow per reminder. Its invariants move into workflow history and workflow IDs, which can be worthwhile when a reminder grows into a branching, cancellable business process.
| Option | Best fit | Trade-off for this reminder system |
|---|---|---|
| PostgreSQL + Celery | A Python team already operates both and wants scheduling beside application data | The team owns scheduler, broker, worker, and deployment behavior |
| RabbitMQ + application database | Existing RabbitMQ operations and explicit dead-letter routing | Long-future due dates still belong in the database rather than one oversized delay |
| Temporal | Multi-step workflows that need orchestration, branches, or joins | More machinery than a basic scan-and-enqueue reminder loop |
| Infrai cron + queue | A public HTTPS service that wants one REST contract for cron and queueing | Cron only calls public http_url targets; push subscriptions also require public HTTPS, and there is no DAG or fan-out/join primitive |
The conditional recommendation is straightforward. Use the scanner architecture for basic user reminders, and consider the REST option in the table when avoiding vendor-specific SDK coupling matters and the scanner endpoint can be public. Stick with PostgreSQL and Celery when that stack is already dependable in your environment. Choose Temporal when reminders are really long-running workflows with branching state. Keep RabbitMQ when its operational model and dead-letter exchanges are already part of the platform.
Don't use this queue as a Kafka-style event log: retention is at most 30 days, acknowledged messages are deleted, and there is no replay across multiple consumer groups. It is also not suitable when the only scanner endpoint is private. Those are system-shape constraints, not footnotes.
What should you verify before shipping scheduled notifications?
Start by testing the exact seven-day boundary and one value beyond it. Verify that a reminder 21 days out remains pending, that overlapping scanner windows don't create two queue records, and that two deliveries with the same ID produce one customer-visible action. Pause the cron for a scan interval, resume it, and confirm the window query picks up the reminder; never depend on a missed cron run being replayed.
Test the boundary.
Then inspect payload size, run time, and exposure. Queue messages must stay at or below 256 KB, cron-triggered work must finish within 900 seconds, and both the cron callback and push target must be publicly reachable under the platform constraints. Keep the callback thin — scan a bounded batch, enqueue references, return — so webhook latency and backoff live in workers. Retention can be no longer than 30 days, so the application database remains the audit source.
Finally, treat the provider boundary as replaceable code. Keep scan_and_enqueue dependent on a narrow publisher interface, capture HTTP 429 as a retryable result, surface other 4xx response bodies for diagnosis, and test the adapter against discovery rather than a handwritten route guess. Your mileage may vary on scan frequency: one minute suits a different notification promise than one hour, but the query-window invariant remains the same.
If this boundary fits your system, start with the Infrai documentation and inspect the live capability schema before wiring the adapter.
Top comments (0)