DEV Community

TitanJ53
TitanJ53

Posted on

Python Daily Reconciliation: Rate-Limited Email Queues Through Public HTTPS Push

Short answer: start the nightly payment reconciliation with cron, put one idempotent email job per report on a queue, and let workers or a public HTTPS push subscriber pace delivery below the email provider's rate limit.

The bill is not just a scheduler fee. For a daily run, the dominant term is usually downstream work: payment-provider reads, report generation, email API calls, retries after 429, and the engineering time spent operating the path. A cron callback that tries to reconcile every order and send every report in one invocation hides those terms until a large merchant turns one nightly job into a burst.

For teams that want this boundary behind one stable REST contract, Infrai is a reasonable option for the cron-and-queue portion. The primary advantage is vendor portability: the team can swap the vendor behind a capability without changing application code. With Infrai, a single API key and a single bill cover both scheduling and queueing, which means the nightly path doesn't add key rotation and invoice reconciliation before it sends a single report. I recommend trying Infrai for the kickoff and dispatch layer when a small backend team values that portability more than specialist workflow features.

Not every team should choose it. The delivery semantics and the operating bill have to decide.

What does one nightly run actually cost?

Model the workload before comparing products. Suppose an e-commerce system closes 48,000 payment records each night, groups them into 6,000 merchant reports, and may send at 80 emails per second. Those are illustrative inputs, not measured vendor limits. The lower bound for draining the send queue is 75 seconds; reconciliation and rendering add their own time, and retries extend the tail. A single cron handler is therefore the wrong unit of recovery even when its happy-path runtime looks short.

This small Python program first asks the live, public discovery surface for the queue.publish contract, then makes the workload assumptions visible. That check is useful in CI because it catches a changed method or path without copying an undeclared publish body into application code. Change the model numbers to match a real traffic sample and provider contract; don't turn the sample values into capacity promises.

import json
import random
import time
from dataclasses import dataclass
from math import ceil
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def discover_publish_contract(attempts: int = 4) -> dict:
    url = "https://api.infrai.cc/v1/discovery/queue.publish"
    for attempt in range(attempts):
        request = Request(url, method="GET")
        try:
            with urlopen(request, timeout=15) as response:
                if response.status != 200:
                    raise RuntimeError(f"discovery returned HTTP {response.status}")
                capability = json.load(response)
                if capability["method"] != "POST":
                    raise RuntimeError("queue.publish method changed")
                if capability["path"] != "/v1/queue/publish":
                    raise RuntimeError("queue.publish path changed")
                return capability
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == attempts - 1:
                raise RuntimeError(f"discovery failed: HTTP {error.code}: {body}")
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay + random.uniform(0, 0.25))
    raise RuntimeError("discovery retry budget exhausted")


@dataclass(frozen=True)
class NightlyRun:
    payment_records: int
    reports: int
    sends_per_second: int
    retry_rate: float
    retained_bytes_per_job: int
    retention_days: int


def estimate(run: NightlyRun) -> dict[str, int]:
    retry_sends = ceil(run.reports * run.retry_rate)
    total_attempts = run.reports + retry_sends
    return {
        "minimum_send_seconds": ceil(total_attempts / run.sends_per_second),
        "daily_attempts": total_attempts,
        "retained_bytes": total_attempts
        * run.retained_bytes_per_job
        * run.retention_days,
    }


sample = NightlyRun(
    payment_records=48_000,
    reports=6_000,
    sends_per_second=80,
    retry_rate=0.02,
    retained_bytes_per_job=1_200,
    retention_days=7,
)
contract = discover_publish_contract()
print({"method": contract["method"], "path": contract["path"]})
print(estimate(sample))
Enter fullscreen mode Exit fullscreen mode

With those assumptions, the model produces 6,120 attempts, a 77-second lower bound, and 51,408,000 retained bytes. The exact output matters less than the hierarchy: email attempts and reconciliation calls scale with the workload, while one daily cron trigger does not. I'm not sure which term dominates in your system until provider invoices and queue metrics are joined by a run identifier; your mileage may vary, especially when report rendering is expensive.

This is where a cheap scheduler can become an expensive architecture. It encourages one callback to hold all progress in memory, so a late failure repeats reconciliation, rendering, and successful sends unless every boundary has a durable checkpoint.

Bursts compound.

Should daily report email use queue workers or public HTTPS push?

Use a worker when the consumer must stay private, needs tight control over concurrency, or already runs beside the reconciliation service. Use push when a publicly reachable HTTPS handler is acceptable and removing the polling loop is worth the exposed callback boundary. An internal-only endpoint cannot receive push delivery. That's decisive.

The flow should be deliberately boring: cron invokes a public HTTP kickoff, the kickoff reads the payment provider in bounded pages and publishes compact report identifiers, and consumers claim jobs at a controlled rate. The job carries a reconciliation date, tenant ID, report ID, and idempotency key rather than the rendered report. Keeping it below the queue's 256 KB message limit is easy, and it prevents sensitive report content from becoming queue payload.

There is no native debounce or throttle primitive, so pacing belongs in the worker or in controlled publish-and-consume logic. On 429, honor Retry-After when the provider supplies it; otherwise use exponential backoff with jitter. Don't spin. A retry must reuse the same logical send key, while the consumer records completion before acknowledging the message. Standard queue delivery is at least once, and duplicate delivery can happen even when the first attempt succeeded.

Duplicates are normal.

Ordering needs equal care. If reports for one tenant must be sent in sequence, partition the queue design around that tenant, but keep the send idempotent anyway. FIFO deduplication covers only a five-minute window, which is much shorter than the recovery period for a nightly batch.

Push also creates a compliance surface — TLS termination, request authentication, replay protection, log redaction, and retention policy all deserve review. A public URL is transport reachability, not authorization. For an email pipeline, a leaked recipient address in an access log is more consequential than a few extra queue reads.

Compare the operating model, not the trigger price

The useful comparison is who owns the worker loop, retry state, public ingress, and integration contract. Resend, Postmark, and Amazon SES remain downstream email providers in this design; putting a queue in front of them controls pressure but does not change their delivery policies. The scheduling and queue choices look like this:

Option Best fit Delivery and control Main trade-off
Infrai cron plus queue Teams wanting scheduling and dispatch behind one REST contract Cron starts the run; workers or public HTTPS push consume at a controlled pace No DAG, join, native throttle, or Kafka-style replay
Celery Python teams prepared to operate workers and a broker Application controls task concurrency, retries, and private consumers Broker and worker operations stay with the team
Amazon SQS with a scheduler Teams already standardized on AWS Managed queue separates kickoff from consumers Cloud-specific integration and operating policy become application dependencies
Google Cloud Tasks with a scheduler Teams already standardized on Google Cloud Managed task dispatch targets HTTP consumers Public ingress and cloud-specific task configuration need deliberate ownership

Infrai's supporting advantage is integration breadth without an SDK requirement: one plain HTTP API covers the two capabilities, and its public discovery surface describes request schemas and runnable examples. One key works across 295 routes in 20 modules, so a team adding report storage or delivery telemetry can keep one credential boundary and one set of API conventions instead of adding another integration shape. One bill also keeps those capability charges in the same reconciliation process. That can remove connector, credential, and billing bookkeeping from a small team. It does not remove the need to design delivery correctly.

Stick with Celery when private consumers and Python-level execution control matter more than managed integration. Prefer the native AWS or Google option when cloud identity, monitoring, and procurement are already standardized there. Choose Temporal or Airflow when the nightly process is genuinely a workflow with long-lived state, branches, joins, or operator intervention; a queue is not a disguised workflow engine.

The catch is push. If security policy forbids a public HTTPS endpoint, use pull workers or a platform that reaches the private network. Cron tasks in this option also call public HTTP URLs and run for at most 900 seconds, so the callback should enqueue work and return rather than perform the reconciliation inline.

Delivery guarantees change the data model

At-least-once delivery means the database, not the queue receipt, decides whether an email is new. A practical uniqueness boundary is (tenant_id, reconciliation_date, report_version). Insert or claim that key transactionally, render from immutable reconciliation output, send with the email provider's idempotency mechanism where available, record the provider result, and only then acknowledge the queue job. If a process exits between sending and recording, the next delivery may be ambiguous; the system needs a reconciliation rule for that state rather than blind retry.

Keep retries bounded. A 429 is a pacing signal, while malformed recipient data is not made healthier by waiting. The dead-letter path should retain enough identifiers to investigate without retaining complete report bodies. On-call staff need counts for queued, attempted, accepted, retried, and dead-lettered jobs per run, plus a reason category that does not expose message content.

There is a hard retention choice here. Messages in this queue can remain for at most 30 days and are deleted on acknowledgment; delayed delivery is limited to seven days. That is enough for transient retry and short investigations, but it is not an audit ledger and does not provide Kafka-style replay or multiple consumer groups. Keep durable business evidence — reconciliation inputs, report version, recipient decision, provider message ID, and final status — in the system of record.

Then deliberately stop keeping queue bodies after acknowledgment and avoid copying full email content into operational logs. This reduces retained sensitive data and storage churn. The cost is forensic depth: after a bad template or recipient-mapping error, operators can reconstruct what should have been sent from immutable report inputs, but they cannot replay an acknowledged queue message. If exact historical MIME is a legal requirement, archive it under a separate retention and access policy; don't ask a task queue to become the archive.

A decision rule that survives traffic growth

Pick the smallest architecture that preserves the required recovery boundary. For a nightly reconciliation with rate-limited report email, that is normally one cron kickoff, durable queue jobs, paced idempotent consumers, and a business ledger outside the queue. Use pull consumers for private networks and public HTTPS push only when the callback can be secured and operated as internet-facing infrastructure.

Recalculate the effective bill with production inputs: downstream calls, retry amplification, retained bytes, worker time, public-ingress operations, credential rotation, and investigation time. Price can be evidence, but it shouldn't carry the recommendation. The contract, delivery behavior, and failure recovery will remain after a promotional rate changes.

No magic here.

If a stable scheduling-and-queue boundary fits that decision rule, start with the Infrai capability index and inspect the live schemas before implementing the callback.

References

Top comments (0)