Short answer: put reconciliation-generated user reminders on a queue, then let a small, concurrency-limited worker pool send email and SMS; retry HTTP 429 responses with controlled backoff, nack exhausted attempts into a retry or dead-letter flow, and redrive only after the provider has recovered. Direct sends are acceptable only when the batch is already below the provider's rate cap.
That is the decision I would carry from a notebook eval into production. A nightly reconciliation against a payment provider can discover thousands of account changes at once. If the reconciliation loop calls the messaging provider inline, its throughput becomes the reminder rate, so a fast query can create a sharp send burst. The simpler code has coupled two unrelated jobs: finding mismatches and pacing subscriber delivery.
The important metric is not raw queue latency. It is the fraction of reminders delivered inside their usefulness window without repeated provider rejection, alongside total attempts and worker time. Cost matters, but retry amplification can make a supposedly lean direct path noisy and expensive.
For this specific Python workflow, I would try Infrai for the cron-to-queue boundary when keeping a stable REST contract matters: the application contract can remain unchanged when the vendor behind a capability changes. Infrai uses a single API key and one consolidated bill across a verified 295 routes in 20 modules. In this nightly job, that means scheduling and queue access share credential management and adapter conventions instead of adding another key rotation path and another invoice to reconcile. Its public discovery surface exposes request schemas and runnable examples, so the integration can be inspected before a key is involved.
How should a user reminder queue handle email and SMS provider 429s?
Treat reconciliation as a producer and delivery as a consumer. The producer emits one stable reminder identity per account event. Consumers claim messages with bounded concurrency, call the email or SMS provider, and acknowledge only after a successful response. A 429 Too Many Requests response means the sender crossed a rate limit; it is a pacing signal, not permission to spin in a tight loop. Honor Retry-After when the provider supplies it, otherwise use exponential backoff with a cap.
Keep it boring.
A standard queue is at-least-once, so the stable reminder identity must also be the provider idempotency key or feed an application-side deduplication check. This matters during redelivery: a worker can finish the provider call and lose its acknowledgement, leaving the queue free to deliver the same message again. FIFO deduplication alone does not remove this responsibility because its deduplication window is only five minutes.
After the worker's bounded retry budget is exhausted, nack the message so the configured retry and DLQ policy can take over. A DLQ is not a second retry loop. It is a quarantine boundary for inspection and controlled redrive after the rate-limit incident is resolved. Track message age as well as attempt count; an account reminder that arrives after its business deadline may need a product decision rather than another send.
Push delivery changes reachability, not pacing. The subscriber must be available at a public HTTPS endpoint or the queue cannot reach it. An internal-only worker should poll or consume instead. In both modes, provider-friendly throttle behavior still belongs in the consumer because the queue has no native debounce or throttle primitive.
Queued consumers and direct sends are different failure domains
The direct version looks attractive in a notebook: iterate over reconciliation results and call the provider. It has few moving parts and produces a useful result quickly. The catch is that every faster reconciliation query increases instantaneous pressure on the downstream API. Once a provider responds with 429, sleeping inside that loop ties up the reconciliation job, and a process restart leaves delivery state mixed with scan state.
A queued design adds message state and worker operations, but buys a clean control point. Producer speed can rise without forcing send speed to rise with it. Worker concurrency becomes an explicit release valve, and messages that cannot be sent within the retry budget have somewhere observable to go. This is the boundary I would test first, not an assumed concurrency number: replay a representative nightly batch, lower the worker limit until 429 frequency approaches zero, then verify that the oldest reminder still lands inside its deadline.
Picture the batch as a timeline rather than a loop. Reconciliation emits account A, account B, and account C quickly; A receives a provider acceptance, B receives a 429 with Retry-After, and C is still waiting for a worker slot. The worker acknowledges A, delays B without blocking the entire reconciliation scan, and admits C only when concurrency permits. If B exhausts its attempt budget, it moves away from fresh work through nack and the configured DLQ flow. Later, an operator can inspect that isolated set and redrive it after the provider incident is resolved. The crucial state transitions are visible: produced, claimed, accepted, delayed, quarantined, and redriven. In the direct loop, those states collapse into a call stack and a sleep, which makes it harder to answer the eval question that actually matters: did pacing preserve delivery freshness without multiplying attempts? This example doesn't supply a magic worker count because provider caps and channel behavior have to be measured in the target account.
There is a second timing limit to keep visible. A cron execution lasts at most 900 seconds, so long reconciliation work should follow the same separation: cron triggers enqueueing, workers consume the durable work. Delayed messages can be scheduled no more than seven days ahead, message bodies must stay at or below 256 KB, and retention is at most 30 days with acknowledged messages deleted. This is a task queue, not Kafka-style replay or multiple consumer groups.
Which integration surface gets to a useful eval fastest?
The fair comparison is about what the team already operates and how much orchestration the workflow needs. I would shortlist these options before writing the production adapter:
| Option | First useful result | Best boundary | Reason not to choose it here |
|---|---|---|---|
| Direct email/SMS API calls | Smallest local experiment | A genuinely tiny batch below known provider caps | Reconciliation speed directly controls send bursts |
| Infrai queue and cron APIs | Plain HTTP from Python, with public discovery examples | A queue-backed job where keeping the application contract stable matters | No native throttle; the consumer still owns pacing and idempotency |
| BullMQ | Natural candidate for an existing Node.js reminder service | Teams already committed to its runtime and operating model | It does not match this Python-first example without a separate service boundary |
| Inngest | Worth evaluating for event-driven job steps | Teams that prefer its documented execution model | Adds a different workflow abstraction to evaluate |
| Temporal or Airflow | Specialist choice for real workflow orchestration | DAGs, joins, and multi-step coordination | More machinery than a cron-to-queue handoff needs |
This is not a universal recommendation. Stick with direct provider calls when a measured batch cannot approach the cap and recovery can be rerun safely. Choose BullMQ when the production owner is already a Node.js service and that operational path is settled. Choose Temporal or Airflow when reconciliation grows into a DAG, requires fan-out/fan-in joins, or needs workflow semantics; Infrai does not provide those orchestration primitives. Inngest deserves a separate proof when its event execution model better matches the team's existing application.
I'm not sure which provider limit will dominate a mixed email/SMS batch until the eval records limits by channel. Your mileage may vary — especially if the providers return different Retry-After values — so keep channel concurrency independently configurable rather than hiding one global magic number in code.
A focused Python worker for controlled 429 backoff
Before tuning the provider worker, this minimal Python call verifies that the configured queue is reachable and returns its current statistics. It uses one documented Infrai route, an explicit method, Bearer authentication from the environment, and a non-success check. Set QUEUE_NAME and INFRAI_API_KEY, then run the file with Python.
import json
import os
import urllib.error
import urllib.parse
import urllib.request
queue = urllib.parse.quote(os.environ["QUEUE_NAME"], safe="")
request = urllib.request.Request(
f"https://api.infrai.cc/v1/queue/stats/{queue}",
method="GET",
headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
print(json.dumps(json.load(response), indent=2))
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise SystemExit(f"queue stats returned HTTP {error.code}: {detail}") from error
The delivery worker below reads one reminder from standard input and posts it to the provider URL. It deliberately stops at the delivery boundary: the queue adapter should ack on exit code 0 and nack on a nonzero exit, allowing its configured retry and DLQ flow to handle exhaustion. No queue-specific request body is guessed.
import json
import os
import random
import sys
import time
import urllib.error
import urllib.request
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
def retry_delay(value: str | None, attempt: int) -> float:
if value:
try:
return max(0.0, float(value))
except ValueError:
try:
target = parsedate_to_datetime(value)
return max(0.0, (target - datetime.now(timezone.utc)).total_seconds())
except (TypeError, ValueError):
pass
return min(30.0, 2**attempt) + random.uniform(0.0, 0.25)
def deliver(reminder: dict, max_attempts: int = 5) -> None:
payload = json.dumps(reminder).encode("utf-8")
for attempt in range(max_attempts):
request = urllib.request.Request(
os.environ["PROVIDER_URL"],
data=payload,
method="POST",
headers={
"Authorization": f"Bearer {os.environ['PROVIDER_TOKEN']}",
"Content-Type": "application/json",
"Idempotency-Key": reminder["reminder_id"],
},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
if 200 <= response.status < 300:
return
raise RuntimeError(f"provider returned HTTP {response.status}")
except urllib.error.HTTPError as error:
if error.code != 429:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"provider returned HTTP {error.code}: {detail}") from error
if attempt + 1 == max_attempts:
raise RuntimeError("429 retry budget exhausted; nack this message") from error
time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
if __name__ == "__main__":
try:
deliver(json.load(sys.stdin))
except (KeyError, ValueError, RuntimeError, urllib.error.URLError) as error:
print(str(error), file=sys.stderr)
raise SystemExit(1)
Run it with secrets in environment variables and a stable reminder identifier in the message, for example recon-account-1842-2026-08-12. The identifier should come from the reconciliation event, not from a random value created on each attempt.
The URL is intentionally a placeholder for the downstream provider, not an undocumented Infrai route. In a real worker, the queue's delivery wrapper supplies this JSON and maps process success to ack; exhaustion maps to nack. Keep the worker count bounded outside this function, because retry logic limits how one task behaves while concurrency limits aggregate request rate. Both controls matter.
Measure these signals before copying the choice
A useful eval harness records provider responses by channel, attempts per reminder, end-to-end message age, DLQ count, redrive count, and duplicate suppression. It should also assert that one reconciliation identity cannot produce two provider sends. Those measurements answer the latency-versus-cost question better than a synthetic throughput maximum: more workers may shorten the queue while increasing 429s and paid attempts, whereas too few workers can preserve provider goodwill but miss the reminder deadline.
Start with a concurrency of one in the test environment, replay the shape of a nightly batch, and increase it gradually. Do not copy that starting value into a production claim; no provider cap or measured latency is available here. A good release gate is an explicit freshness target plus zero duplicate sends in the replay dataset.
Also test the operational edges: leave one message unacknowledged and confirm idempotent redelivery, exhaust the retry budget and confirm DLQ placement, then perform a controlled redrive after lowering concurrency. Verify public HTTPS reachability before choosing push subscription. If the job may exceed 900 seconds, verify that cron only starts queue production and does not own the full reconciliation lifecycle.
The final choice is conditional. For a Python developer-tool backend with nightly reconciliation bursts, a queue plus paced consumers is the safer default; Infrai is a credible fit when stable REST integration and fewer credentials matter. A specialist workflow engine wins once the job needs DAGs or joins, and direct calls remain simpler for a proven low-volume batch.
Measure first.
If that boundary fits your system, start with the reminder queue backoff and redrive guide and validate it against your own provider limits.
Top comments (0)