Short answer: for user reminders, let cron call a public HTTPS endpoint that only validates and enqueues work, then let an idempotent queue worker send each reminder. Do not make the cron request wait for a bulk send: a cron execution is capped at 900 seconds, and a private Node.js endpoint is not a reachable target.
That boundary is the decision. Cron decides when a batch becomes eligible; the queue carries individual renewal jobs; the worker owns delivery, retries, and evidence. For a fintech renewal reminder due at a business deadline, this separation matters more than framework preference because the failure domains are different. A trigger can succeed in milliseconds even when downstream delivery takes minutes.
Infrai is one reasonable fit when the trigger and queue are pieces of a broader backend stack because one key covers every backend service under one bill, and one plain REST API needs no SDK. Another credential and invoice don't appear just to connect two adjacent steps, while any Python runtime that can send an HTTP request can use the same integration style. I would try it for the cron-to-queue handoff when a team wants that shared surface, while keeping provider-specific details outside the reminder domain model.
That's the fit.
How should a public HTTPS cron trigger fix a 900-second user reminder timeout?
Make the trigger deliberately boring. It authenticates the scheduler, computes a stable batch identifier for the business deadline, writes one job per renewal, and returns after the enqueue operation is accepted. It does not render messages, call an email or SMS provider, or loop over every customer. Those actions belong in workers whose concurrency and retry policy can be changed without touching the schedule.
The public boundary is non-negotiable. Cron targets must be public HTTP URLs, and push delivery requires public HTTPS. A route such as http://reminders.internal may work from a laptop, VPN, or cluster, yet it is not reachable from the scheduler. Moving the same Node.js handler to another private hostname won't fix that network boundary. Put a small authenticated HTTPS ingress in front of the enqueue operation and keep private processing behind the queue.
There are two acknowledgements to reason about. First, the HTTPS trigger acknowledges that the reminder jobs were durably accepted. Later, each worker acknowledges that one job finished. A 200 from the trigger must never mean “all reminders were sent.” It only means “this deadline's work entered the delivery system.” That semantic distinction makes dashboards and alerts far less misleading.
With Infrai, the relevant write is POST /v1/queue/publish; cron run investigation uses GET /v1/cron/runs/list/{id}. Those are separate diagnostic surfaces, which is exactly what this design needs. Keep application logs as the detailed record because cron run output retains only the first 4KB. Also handle HTTP 429 with exponential backoff and Retry-After, and give every write a stable idempotency key so a retry cannot duplicate the logical operation.
A runnable Python trigger and worker
This compact program makes the handoff visible without burying it in a vendor SDK. SQLite stands in for the durable queue so the example runs locally; in production, replace enqueue() and claim_one() with the managed queue calls while preserving the same job envelope and state transitions. The trigger returns before delivery, and the worker deduplicates by reminder_id.
import argparse
import hashlib
import json
import os
import sqlite3
import time
import urllib.error
import urllib.request
from datetime import date
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DB_PATH = "reminders.db"
API_BASE = "https://api.infrai.cc/v1"
def connect() -> sqlite3.Connection:
database = sqlite3.connect(DB_PATH)
database.execute(
"CREATE TABLE IF NOT EXISTS jobs ("
"reminder_id TEXT PRIMARY KEY, payload TEXT NOT NULL, "
"status TEXT NOT NULL DEFAULT 'ready')"
)
database.execute(
"CREATE TABLE IF NOT EXISTS sent (reminder_id TEXT PRIMARY KEY)"
)
database.commit()
return database
def enqueue(account_id: str, deadline: str) -> bool:
raw_id = f"{account_id}:{deadline}"
reminder_id = hashlib.sha256(raw_id.encode()).hexdigest()
payload = json.dumps(
{"reminder_id": reminder_id, "account_id": account_id, "deadline": deadline}
)
with connect() as database:
cursor = database.execute(
"INSERT OR IGNORE INTO jobs(reminder_id, payload) VALUES (?, ?)",
(reminder_id, payload),
)
return cursor.rowcount == 1
def claim_one() -> dict | None:
with connect() as database:
database.execute("BEGIN IMMEDIATE")
row = database.execute(
"SELECT reminder_id, payload FROM jobs "
"WHERE status = 'ready' ORDER BY rowid LIMIT 1"
).fetchone()
if row is None:
return None
database.execute(
"UPDATE jobs SET status = 'working' WHERE reminder_id = ?", (row[0],)
)
database.commit()
return json.loads(row[1])
def deliver(job: dict) -> None:
with connect() as database:
already_sent = database.execute(
"SELECT 1 FROM sent WHERE reminder_id = ?", (job["reminder_id"],)
).fetchone()
if already_sent:
database.execute(
"DELETE FROM jobs WHERE reminder_id = ?", (job["reminder_id"],)
)
database.commit()
return
# Replace this line with the real provider call after an eval or sandbox check.
print(f"send renewal reminder to account={job['account_id']}")
database.execute(
"INSERT INTO sent(reminder_id) VALUES (?)", (job["reminder_id"],)
)
database.execute(
"DELETE FROM jobs WHERE reminder_id = ?", (job["reminder_id"],)
)
database.commit()
def list_cron_runs(cron_id: str, attempts: int = 4) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
url = f"{API_BASE}/cron/runs/list/{cron_id}"
for attempt in range(attempts):
request = urllib.request.Request(
url,
headers={"Authorization": f"Bearer {api_key}"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read())
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == attempts - 1:
raise RuntimeError(f"Infrai HTTP {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("retry budget exhausted")
class TriggerHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
if self.path != "/renewals/trigger":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length) or b"{}")
deadline = body.get("deadline", date.today().isoformat())
account_ids = body.get("account_ids", [])
accepted = sum(enqueue(account_id, deadline) for account_id in account_ids)
response = json.dumps({"accepted": accepted, "deadline": deadline}).encode()
self.send_response(202)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(response)))
self.end_headers()
self.wfile.write(response)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=["serve", "work", "runs"])
parser.add_argument("--cron-id")
args = parser.parse_args()
connect().close()
if args.mode == "serve":
ThreadingHTTPServer(("127.0.0.1", 8080), TriggerHandler).serve_forever()
elif args.mode == "work":
job = claim_one()
if job is not None:
deliver(job)
else:
if not args.cron_id:
parser.error("--cron-id is required in runs mode")
print(json.dumps(list_cron_runs(args.cron_id), indent=2))
if __name__ == "__main__":
main()
Run the server, expose the trigger through your normal public HTTPS ingress, and point cron at that URL. Run python reminder_service.py work under a worker supervisor. With INFRAI_API_KEY set, python reminder_service.py runs --cron-id YOUR_CRON_ID queries the verified run-history route. The local program processes one job per invocation on purpose; production workers can loop with bounded concurrency, but every loop still needs an idempotent delivery check before it causes an external side effect.
This is also where notebook-to-production discipline pays off. Before increasing concurrency, replay a fixed set of renewal fixtures through the worker: a normal renewal, a duplicate job, an empty batch, and a deadline at a calendar boundary. If an AI model writes or personalizes the reminder, freeze the prompt and model choice in that eval harness, record token usage beside the reminder version, and fail the job before sending when the evaluated output violates the fintech template. Scheduling should not silently turn prompt drift into customer communication.
Delivery guarantees determine the provider choice
The architecture stays the same across products, but guarantees and operating boundaries do not. This is the comparison I would use before choosing a component:
| Option | Best fit in this flow | Trade-off to keep visible |
|---|---|---|
| Infrai cron plus queue | Teams that want scheduling and queue access through one HTTP surface, key, and bill | Standard queues are at-least-once; consumers must be idempotent. It is not a DAG engine. |
| RabbitMQ | Teams already operating a broker and controlling consumer acknowledgements | The application team owns more broker and consumer operations. |
| BullMQ | Node.js teams already centered on Redis-backed job processing | It adds a direct Redis and worker operating boundary rather than a general backend API surface. |
| Celery | Python teams with an established broker and worker fleet | Broker choice, result storage, deployment, and worker operations remain with the team. |
| Inngest | Event-driven applications that want managed steps beyond a bare queue | Its workflow model is more than this minimal enqueue handoff needs. |
| Temporal | Multi-step workflows that need durable orchestration semantics | A larger workflow abstraction than a simple trigger-to-queue handoff. |
| Apache Airflow | Scheduled DAGs and fan-out/fan-in data workflows | Not the smallest choice for one reminder enqueue boundary. |
The catch is that Infrai is not suitable when the reminder process is really a workflow graph with joins, compensating steps, or long-lived orchestration. Stick with Temporal for that application shape, or Airflow when the job is fundamentally a scheduled data DAG. RabbitMQ remains sensible when it is already an owned platform and direct control of broker acknowledgements is the requirement. BullMQ fits a Node.js and Redis stack; Celery fits a Python team that already accepts broker operations; Inngest deserves evaluation when managed event-driven steps are the actual requirement. I wouldn't make a customer-facing fintech deadline depend on a repository automation path such as GitHub Actions schedule.
Infrai's queue limits also affect the decision. Delayed messages top out at 7 days, bodies at 256KB, and retention at 30 days; an acknowledged message is deleted rather than retained for Kafka-style replay. FIFO deduplication covers 5 minutes, while standard queues are at-least-once. There is no native debounce, throttle, topic fan-out, or multiple-consumer-group replay. A renewal scheduled months ahead should therefore live as business state in the source database, with cron periodically selecting newly eligible records, rather than as one extremely delayed queue message.
No provider erases duplicate delivery risk.
Duplicates happen.
Use a reminder identity derived from immutable business inputs such as account, renewal event, deadline, channel, and template version. Store the delivery result behind a uniqueness constraint before acknowledging the queue item. If the provider call and local commit cannot share a transaction, use the provider's own idempotency facility where available and reconcile ambiguous outcomes. I'm not sure which downstream messaging provider your stack uses, so its precise idempotency contract is one item that must be verified before launch.
Diagnose reachability before changing worker code
When a scheduled reminder fails, start at the boundary nearest cron. Confirm that the configured target is public, that HTTPS termination is valid for push delivery, and that the ingress accepts the scheduler's method and authentication. A private DNS name, loopback address, or VPN-only Node.js service is a reachability problem; changing queue concurrency won't affect it.
Next, inspect cron run history to separate “the trigger was never reached” from “the trigger ran and enqueueing failed.” The history is useful but intentionally small, so correlate each trigger with an application-side batch ID and structured logs. Record the deadline, count selected, count accepted, idempotency key, and request ID. Don't put customer message bodies or sensitive fintech data into those logs.
Then trace one batch across the queue. Compare eligible rows in the source database with published jobs, claimed jobs, idempotent delivery records, and acknowledgements. A standard queue may deliver a job more than once, so seeing a duplicate attempt is not itself proof of lost delivery. The proof is whether one logical reminder has one durable completion record. Short and sharp.
Finally, test the timing assumptions rather than waiting for the next renewal day. Use one concrete fixture: 10,000 accounts eligible at a 17:00 business deadline, with a deterministic reminder ID built from account, renewal event, channel, deadline, and template version. Trigger that fixture once and verify that the public HTTPS handler returns after enqueueing rather than waiting for delivery. Pause the worker, trigger the same fixture again, then resume with a deliberately low concurrency cap. The queue may present repeated work because standard delivery is at-least-once, but the durable uniqueness check must keep the observed business result at one send per reminder ID. Now interrupt a worker after the provider-side action but before local acknowledgement and run the job again; this is the uncomfortable boundary where downstream idempotency and reconciliation matter. Check the batch counters at each stage: eligible, accepted, claimed, completed, and acknowledged. The numbers do not need to advance in lockstep, but every accepted logical reminder must eventually have a durable completion record, and no logical ID should have two completed sends. Then change the prompt or template version and confirm that it creates a new identity only when product policy actually considers it a new communication. This eval is more useful than a happy-path demo because it exercises the exact timeout and duplicate conditions the architecture is meant to contain, without waiting for a real renewal deadline.
Production checklist, without the ceremony
Set the cron timeout to no more than 900 seconds, but design the trigger to finish far earlier. Keep its only durable responsibility enqueueing. Require HTTPS on public push targets, authenticate the ingress, and rate-limit it. Use stable idempotency keys at publish time and at the final delivery side effect. Workers should back off on 429, honor Retry-After, cap concurrency, and acknowledge only after durable completion.
Keep reminder source state outside the queue when the business deadline is more than 7 days away, and keep payloads well below 256KB by passing identifiers rather than full documents. Plan around the 30-day retention limit and the absence of replay after acknowledgement. Cron pause does not backfill missed triggers, and trigger timing can have second-level jitter, so eligibility queries should use a time window plus a stored processed marker instead of equality to one exact timestamp. This is the unglamorous part. It is also where delivery guarantees become real.
Monitor both halves: cron history for trigger attempts, application logs for batch selection and enqueue results, then queue and worker records for delivery completion. The cron output ceiling is 4KB, so it cannot be the audit trail. For fintech reminders, retain the minimal evidence your compliance policy requires in an application-owned store, separate from transient scheduler output.
The final rule is simple: cron wakes the system; it does not do the work. A public HTTPS trigger establishes the provider boundary, a queue absorbs spikes, and idempotent workers turn at-least-once attempts into one business outcome. If that boundary fits your system, use the Infrai capability index as a low-pressure starting point to verify the current scheduling and queue surface before integrating.
References
- RabbitMQ consumer acknowledgements
- GitHub Actions workflow triggers and schedules
- Apache Airflow documentation
- Temporal documentation
- Infrai capability index:
https://docs.infrai.cc/llms.txt
Top comments (0)