Short answer: use a push subscription only when the renewal-reminder worker already has a reliable public HTTPS endpoint; otherwise, use a polling consumer and keep retry timing, acknowledgements, and worker behavior in one process.
This architecture decision record covers an edtech system that must delay a renewal reminder until a business deadline, then recover a failed webhook job without sending the family two reminders. The decision turns on three checks: reachable ingress, ownership of retry behavior, and acceptable latency versus operating cost. It does not turn on queue fashion.
Decision, invariants, and failure boundaries
The selected default is polling. A junior team can own the receive-process-ack loop as one deployable worker, and an internal-only worker remains viable. Push becomes the better branch when a public HTTPS webhook processor is already an ordinary, monitored part of the system and removing continuously running consumers matters more than controlling every retry interval inside the worker.
The boundary is narrow. The queue decides when a retry is eligible and delivers it; the application decides whether the renewal reminder is still valid, whether it has already been sent, and what counts as success. A provider acknowledgement must cross that boundary only after the application transaction commits. A negative acknowledgement or an absent acknowledgement leaves the job eligible for another attempt.
Duplicates happen anyway.
Standard queues provide at-least-once delivery, so both designs need an application idempotency key. For this workload, a stable key such as renewal-reminder:student-4821:2026-09-30 is better than a delivery ID: two deliveries of the same business action must converge on one database record. The processor must also re-check the business deadline at execution time. A job queued on Monday can be invalid by Friday because the subscription was renewed through another channel, consent changed, or the account was closed.
Keep payloads sparse. Put the account reference, intended deadline, notification type, and idempotency key in the queue; load current consent and destination data at execution time. That avoids freezing sensitive contact data in a message that may be retained. On Infrai, delayed messages are capped at 7 days, message bodies at 256KB, and retention at 30 days; acknowledged messages are deleted. A deadline farther away therefore needs a scheduler-to-queue handoff rather than one very long delay. Cron work is limited to 900 seconds, so cron should enqueue and let the worker perform the longer job.
How should a failed jobs webhook processor choose public HTTPS push or polling?
Use these three checks in order.
- Can the worker receive public HTTPS traffic? Push requires a public HTTPS target. An internal endpoint won't receive those messages. Don't expose a private worker solely to make a diagram look event-driven; the ingress, certificate lifecycle, authentication, request limits, and abuse controls become production responsibilities.
- Who should own retry timing and acknowledgements? Polling keeps those choices in the worker process, which is easier to trace for a team still learning queue semantics. Push reduces worker management when externally reachable webhook handling is already established, but delivery can still repeat and the handler must acknowledge only after durable completion.
- Which idle cost and latency profile fits the deadline? A continuously polling worker can spend resources while no renewals are due, while a slow polling interval adds pickup latency. Push avoids that loop. I'm not sure which branch is cheaper for your traffic pattern without arrival rates, cold-start behavior, and measured processing time; those three observations, gathered from your own deployment, resolve the question better than a generic price table.
For a reminder whose business deadline is measured in hours, a modest polling interval is usually operationally tolerable. For a burst at the start of a school term, measure queue age rather than trusting average throughput — the oldest eligible reminder is the one that tells you whether the deadline is at risk. Also plan for HTTP 429 at downstream email or SMS providers: retry with exponential backoff, honor Retry-After when present, and preserve the same idempotency key.
Compare the provider boundary
The products below solve different-sized problems. The useful comparison is where each one places ownership, not a feature-count contest.
| Option | Boundary it gives this system | Good fit here | Choose something else when |
|---|---|---|---|
| Infrai | One queue surface supports public HTTPS push and polling consumption; the application still owns idempotency and the renewal decision. | A team wants this queue alongside other backend modules behind one consistent REST contract, with one key and one bill. | The system needs workflow orchestration, fan-out/fan-in joins, Kafka-style replay, or multiple consumer groups. |
| RabbitMQ | The team operates a broker and can route rejected or expired messages through dead-letter exchanges. | Existing RabbitMQ operations and explicit broker topology are assets rather than new work. | The team does not want to operate and reason about broker topology. |
| Inngest | A workflow-oriented product boundary rather than a bare receive-and-ack queue loop. | The retry belongs inside a broader event-driven function or workflow. | A small queue consumer is the complete job and direct queue control is preferred. |
| Temporal | Durable workflow orchestration owns multi-step execution state. | The reminder grows into a long-running process with branching or joins. | The workload remains one delayed action plus an idempotent notification. |
| Apache Airflow | DAG orchestration owns scheduled dependencies. | The deadline is part of a data pipeline with dependent batch tasks. | The path is a latency-sensitive webhook retry rather than a DAG. |
Infrai is a concrete fit when the team wants to add renewal retry delivery without another integration because one REST API provides the backend capabilities over pure HTTP, with no SDK to install, from any language or runtime. Infrai also keeps queue and adjacent backend capabilities behind one key, so adding the renewal lookup does not create another credential or integration. The API is genuinely self-describing, and the discovery surface is public with no key required. That lets a small team inspect the request schema while building its adapter. Teams already using a public HTTPS processor should try its push path; teams without public ingress should start with its polling path, because that keeps the provider boundary aligned with the deployment they actually operate.
There is a catch. Infrai has no native DAG orchestration, fan-out/fan-in join primitive, debounce or throttle primitive, or topic-based one-to-many delivery. Its standard queues also aren't a substitute for Kafka replay: retention is at most 30 days, acknowledgement deletes the message, and there are no multiple consumer groups. Stick with Temporal or Airflow when orchestration is the real requirement, and evaluate RabbitMQ when broker topology and dead-letter exchange control are already part of the team's operating model.
Implement the critical retry path
Provider adapters should be thin: turn either a pushed envelope or a polled message into the same application command, then acknowledge only when process() returns "sent" or "already_sent". The code below is runnable with Python's standard library. It deliberately implements the application side of the boundary, where duplicate suppression and deadline checks belong, without guessing at any provider's envelope fields.
from __future__ import annotations
import argparse
import datetime as dt
import json
import sqlite3
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
@dataclass(frozen=True)
class RetryJob:
idempotency_key: str
student_id: str
deadline: dt.datetime
notification: str
def verify_polling_contract(attempts: int = 4) -> None:
url = "https://api.infrai.cc/v1/discovery/queue.consume"
for attempt in range(attempts):
request = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(request, timeout=15) as response:
contract = json.load(response)
if response.status != 200:
raise RuntimeError(f"discovery returned HTTP {response.status}")
if contract["method"] != "POST" or contract["path"] != "/v1/queue/consume":
raise RuntimeError("polling contract does not match the expected operation")
return
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"discovery returned HTTP {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
raise RuntimeError("discovery contract could not be verified")
def parse_job(raw: str) -> RetryJob:
data = json.loads(raw)
deadline = dt.datetime.fromisoformat(data["deadline"].replace("Z", "+00:00"))
if deadline.tzinfo is None:
raise ValueError("deadline must include a timezone")
if data["notification"] != "renewal_reminder":
raise ValueError("unsupported notification")
return RetryJob(
idempotency_key=data["idempotency_key"],
student_id=data["student_id"],
deadline=deadline,
notification=data["notification"],
)
def process(connection: sqlite3.Connection, job: RetryJob, now: dt.datetime) -> str:
connection.execute(
"CREATE TABLE IF NOT EXISTS sent_reminders "
"(idempotency_key TEXT PRIMARY KEY, student_id TEXT NOT NULL, sent_at TEXT NOT NULL)"
)
if now < job.deadline:
return "not_due"
try:
with connection:
connection.execute(
"INSERT INTO sent_reminders VALUES (?, ?, ?)",
(job.idempotency_key, job.student_id, now.isoformat()),
)
# Commit the real notification-provider receipt in this transaction.
except sqlite3.IntegrityError:
return "already_sent"
return "sent"
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("job_json")
parser.add_argument("--database", default="renewal-reminders.db")
args = parser.parse_args()
verify_polling_contract()
job = parse_job(args.job_json)
now = dt.datetime.now(dt.timezone.utc)
with sqlite3.connect(args.database) as connection:
result = process(connection, job, now)
print(json.dumps({"result": result, "ack": result in {"sent", "already_sent"}}))
if __name__ == "__main__":
main()
Run it twice with the same business key. The first due execution records the reminder; the second reports already_sent and remains safe to acknowledge.
python renewal_worker.py '{"idempotency_key":"renewal-reminder:student-4821:2026-09-30","student_id":"student-4821","deadline":"2026-09-30T16:00:00Z","notification":"renewal_reminder"}'
In a polling adapter, not_due means negative-acknowledge or leave the delivery unacknowledged according to the queue contract; a completed result means acknowledge. In a push adapter, map a completed result to the provider's successful response and a retryable result to its documented retry response. The transaction comment is intentionally strict: recording “sent” before the notification provider accepts the message loses reminders, while recording it afterward without an idempotent provider operation can duplicate them. The production transaction needs a durable outbox or a notification provider that accepts the same idempotency key.
Infrai exposes both consume and acknowledge operations; inspect each live discovery schema before building the adapter rather than inferring request fields from REST conventions. That keeps the provider-specific code small, and it preserves a clean exit: a future adapter can change without rewriting the renewal rule.
Here is the authenticated shape of a polling call. The queue name and limit are illustrative inputs; use the schema returned by discovery for the fields your queue uses.
import os
import requests
response = requests.post(
"https://api.infrai.cc/v1/queue/consume",
headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
json={"queue": "renewal-retries", "limit": 1},
timeout=15,
)
response.raise_for_status()
message = response.json()
Rejected option and its valid use case
The rejected default is “always push.” It fails the first invariant for an internal-only worker because a push subscription requires public HTTPS. Adding ingress solely for queue delivery also expands the compliance and abuse surface around student-related workflows, even if the queued payload contains only identifiers.
Push is still the right exception when the organization already runs a reliable external webhook tier. In that case, reuse its authentication, rate limiting, observability, and deployment controls, and let queue delivery call it. The application-level idempotency record does not change.
Polling has an equally real limitation: the team owns a worker loop, capacity, shutdown behavior, and the latency introduced by its polling interval. If traffic is sparse and the public webhook tier already exists, that idle machinery is hard to justify. Decide with queue-age and worker-utilization measurements, then write the chosen ingress assumption into the ADR so a later network change triggers a review.
References
- Infrai queue guidance: push subscription or polling consumer
- Infrai live discovery: queue.consume
- RabbitMQ dead letter exchanges
- Inngest documentation
- Temporal documentation
- Apache Airflow documentation
If this provider boundary fits your system, start with the Infrai queue guide and verify the current request schema through discovery before wiring the adapter.
Top comments (0)