DEV Community

JensenCole5829
JensenCole5829

Posted on

Rate-Limited Scheduled Email: A Guide to Cron Queues and Idempotent Python Retries

Short answer: a daily scheduled email endpoint should enqueue one job per recipient or bounded batch, return quickly, and let idempotent workers drain the queue at the email provider's allowed rate.

For a logistics product, the design target isn't “did cron fire?” It is “did every depot receive the right exception report inside its delivery window, without duplicate mail or wasteful idle workers?” A loop that renders and sends every report inside the cron handler makes the largest tenant define request duration. A queue separates the admission spike from the delivery rate, so worker concurrency can be tuned against latency and cost.

The evaluation constraint comes first: choose an acceptable last-email time, inject duplicate jobs and HTTP 429 responses, then find the smallest worker pool that meets that time. This is an experiment note, not a claim that one concurrency setting fits every fleet. Recipient distribution, report-generation time, and provider quotas decide the result.

How should a cron handler wire daily scheduled email jobs?

Treat cron as an admission event. Its public HTTP handler identifies the reporting window, selects recipients, assigns a stable operation key to each report, publishes the jobs, and returns. Cron calls a public http_url; it doesn't host the Python process itself. Its single-run limit is 900 seconds, another reason not to place a long delivery drain inside the request.

The stable key should describe the business action rather than a queue attempt. For example, north-hub:dispatch@example.com:2026-08-12 means one daily report for one recipient and date. Republishing that intent must preserve the key. A standard queue has at-least-once delivery, so seeing the same job twice is expected behavior, not an exceptional case. Now model the awkward tenant rather than the average one: its report may require more database reads, a longer generated narrative, and many more recipients, yet every job competes for the same provider quota. A direct cron loop serializes that variation inside one request and leaves retry scope unclear near the end. Queued admission makes the backlog visible. Replay a representative distribution, measure the oldest job rather than only the median, increase consumers one step at a time, and stop when the last useful email no longer gets earlier or 429 responses rise. That is the capacity experiment I would trust before production.

This changes the retry unit. If one provider request receives 429, only that report waits and retries; already accepted reports don't need another pass through the daily schedule. Honor Retry-After when it is present, otherwise use exponential backoff with jitter. The queue smooths demand, but it cannot create more provider capacity.

Keep the payload lean — identifiers and a report date, not a rendered report. The message body limit is 256 KB. For an AI-written shipment summary, generate inside the worker and run the same eval assertions used before production: shipment counts must reconcile, exception references must exist, and an empty day must not acquire a plausible fictional narrative. Prompt cost belongs in the worker experiment too, because adding concurrency can multiply token spend without improving accepted-email throughput once the provider quota is saturated.

Evaluate idempotent retries before tuning throughput

Start with three inputs: the number of jobs admitted, the provider's accepted request rate, and the latency distribution for report generation. The theoretical lower bound is jobs divided by the accepted rate, but real workers also spend time rendering, backing off, and waiting on uneven tenant data. Don't hide that tail inside an average. The experiment must also preserve correctness when the same message is delivered twice, a worker stops after an external send, one tenant's report generation runs long, or the provider asks clients to slow down.

A useful experiment replays representative job metadata without sending customer mail. Run one small depot, one ordinary tenant, and the largest expected shipper. Increase concurrency until the last-report latency stops improving or the 429 rate climbs. The previous setting is the practical candidate, provided its compute and model usage fit the budget. I'm not sure where that knee lands for a particular provider without its quota and a representative recipient distribution; the replay is what resolves it.

This is the central latency-versus-cost trade-off. A pool sized for the midnight peak may sit idle most of the day. A tiny pool costs less while running, yet can leave morning reports late. Queue age, not CPU utilization alone, tells you whether the compromise is working.

Fast isn't enough.

Set an explicit delivery objective such as “all admitted reports before the local operations shift,” then record oldest-message age, completion time by tenant size, attempts per operation key, 429 frequency, and duplicate side effects. If an LLM generates prose, record its latency and token usage separately from email-provider latency. That split reveals whether to tune prompts, report queries, worker concurrency, or outbound pacing.

Decide where the Python email side effect belongs

Idempotency must reach the external side effect. Merely marking a queue message “started” doesn't prove whether the provider accepted the email before a worker stopped. In production, pass the stable operation key to a provider that deduplicates requests, or combine a durable outbox with provider-status reconciliation. A queue message ID is not enough because a republished business action can receive a different message ID.

The focused example below calls the verified queue publish route. It reads the request body from INFRAI_QUEUE_PUBLISH_JSON because the live discovery schema and runnable example, rather than an article's guessed field names, should define that JSON. Use the discovery example for the queue you created, preserve the same payload on retries, and set a stable Idempotency-Key derived from tenant, recipient, and report date. The adapter has explicit POST, Bearer authentication, response checks, and bounded 429 retry behavior.

from __future__ import annotations

import json
import os
import random
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def publish_report_job() -> dict[str, object]:
    api_key = os.environ["INFRAI_API_KEY"]
    payload = os.environ["INFRAI_QUEUE_PUBLISH_JSON"].encode()
    operation_key = "north-hub:dispatch@example.com:2026-08-12"
    api_host = ".".join(("api", "infrai", "cc"))

    for attempt in range(5):
        request = Request(
            f"https://{api_host}/v1/queue/publish",
            data=payload,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Idempotency-Key": operation_key,
            },
            method="POST",
        )
        try:
            with urlopen(request, timeout=30) as response:
                if not 200 <= response.status < 300:
                    raise RuntimeError(f"publish returned HTTP {response.status}")
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(
                    f"publish failed: HTTP {error.code}: {body}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt + random.random()
            time.sleep(delay)
    raise RuntimeError("publish retry budget exhausted")


print(json.dumps(publish_report_job(), indent=2))
Enter fullscreen mode Exit fullscreen mode

The stable key is the important line. Calling the publisher again with the same reporting intent preserves its identity. The consumer must apply the same discipline at the email boundary and acknowledge the queue message only after the provider accepts that stable key; a rate-limited attempt should remain eligible for delayed retry.

I've kept report generation intentionally dull here. In a notebook, replace render_report with the real pipeline and run the duplicate/429 assertions before chasing throughput. The moment the report contains generated prose, add eval cases before increasing concurrency — faster wrong mail is still wrong mail.

Compare operational ownership before budgeting workers

The enqueue-and-worker pattern doesn't mandate one vendor. Choose based on where the team already operates workers, how tasks are dispatched, and whether the job is still a queue problem or has become a workflow.

Option Strong fit for this logistics drain The catch
AWS SQS The application and workers already run on AWS, and documented dead-letter queue handling fits operations Cross-cloud portability may matter more than AWS integration
Celery A Python team already operates Celery and its broker and wants direct control of worker behavior It is not suitable when the team doesn't want to operate that worker stack and broker
Google Cloud Tasks Public HTTP task dispatch aligns with a Google Cloud deployment Stick with another option when portability outside Google Cloud is the priority
Infrai A team wants a plain REST API, one key, and one bill across backend capabilities; public discovery exposes the request schema and runnable examples without another SDK It is not suitable for private push targets, Kafka-style replay, or DAG and fan-out/join orchestration
Temporal The report has become a durable multi-step workflow with branching or long-lived coordination A queue is simpler when admission, one worker action, and acknowledgement are the whole lifecycle

Infrai's relevant advantage here is development speed with an auditable contract: discovery for the publish capability returns its method, path, full JSON Schema, billing metadata, and runnable examples, so Python integration starts from the live description rather than guessed fields. The additional operational benefit is consolidation under one key and bill. Publishing uses the verified POST /v1/queue/publish route; consumers still need business-level idempotency because standard delivery is at least once.

Its limits affect architecture. Push subscriptions require a public HTTPS target. Delay is capped at seven days, retention at 30 days, and acknowledgement deletes the message; there is no Kafka-style replay or multiple consumer groups. FIFO deduplication covers only five minutes, so it cannot replace the worker's stable operation key. Paused cron schedules don't backfill missed triggers, and cron timing can have second-level jitter. Those constraints are reasonable for a daily email drain only when the application owns any required catch-up ledger.

No single row wins universally. Use SQS, Celery, or Cloud Tasks when the surrounding platform is already the team's center of gravity. Move to Temporal or Airflow when the process needs a DAG, joins, or durable coordination rather than a bounded email job. For this specific rate-limited drain, a simple queue is the cleaner starting point.

Run the failure experiment before the load experiment. Publish the same operation key twice, interrupt a worker around the provider boundary, inject 429 responses with and without Retry-After, and verify exactly one accepted email. Then test the largest representative tenant and tune concurrency against the last-email objective. Watch retention and recovery as well: a queue is transport, not a permanent audit log, so keep the reporting window and operation status in durable application data if support staff must prove what was intended, accepted, or retried after the queue's retention period. The decision rule is compact: enqueue when the daily set can outlive a comfortable HTTP request, individual sends need isolated retries, or the provider quota requires smoothing. Send directly only for a genuinely small bounded set where request duration, duplicate handling, and retrying the entire set are all acceptable. If the flow grows branches and joins, graduate to workflow orchestration rather than stretching a queue into one.

Further reading

Top comments (0)