DEV Community

SvenNilsson228
SvenNilsson228

Posted on

Python Fintech Email Dispatch: Per-User Timezone Logic Behind One UTC Cron

Use one frequent UTC tick, then let application code decide which account is due in its own timezone. Short answer: for a US/EU fintech SaaS, cron should wake the system, while a durable per-account ledger should control daily report email eligibility, retries, and cleanup.

This division matters because a web request is the wrong lifetime for periodic work. The scheduler calls a public handler; the handler finds due accounts, records deterministic delivery keys, removes expired report artifacts, and hands long-running generation to workers. It doesn't wait for every PDF, model summary, or email to finish.

Keep the promise at local-day granularity, not at an exact second. Cron timing can have second-level jitter, and paused triggers are not backfilled automatically. A design that promises "08:00 local" should therefore mean the first healthy tick whose evaluation window includes 08:00, with reconciliation for missed local dates.

That is the whole split.

How should a SaaS handle daily report email timezone logic with UTC cron?

Store an IANA timezone such as America/New_York or Europe/Berlin for each report subscription. Avoid storing a fixed offset: New York's offset changes, and US and European daylight-saving transitions do not occur on the same dates. At every UTC tick, convert the evaluation instant into each subscription's zone and compare a local schedule against a durable delivery ledger.

A ten-minute UTC cron is a coarse clock, not the source of business truth. Suppose an account requests its report at 08:00. The application asks whether 08:00 in that account's zone fell after the previous successful evaluation and no later than the current evaluation. It then derives an idempotency key from the account, report type, and local date. Retrying the same local date produces the same key, so an at-least-once worker cannot create a second logical delivery.

Use a calm local time, too. Scheduling at 08:00 avoids the missing and repeated wall-clock ranges around typical DST transitions. If the product must support arbitrary local times, define policy explicitly: advance a nonexistent time to the next valid instant, and choose the first or second occurrence of an ambiguous time. I'm not sure there is one universally correct choice; product language and an eval set covering both DST boundaries should settle it.

Complex calendar rules belong here as well. Standard-style cron expressions do not support nonstandard extensions such as L, so "last business day in the account's jurisdiction" needs a calendar function and tests, not a clever expression.

A runnable Python selector and cleanup handler

The following program models the important production boundary. It uses zoneinfo, a SQLite ledger, a deterministic delivery key, and a small US/EU fixture. Run it as-is with Python 3.11 or later. The tick function is what a public cron callback would invoke; its returned jobs are what the callback would publish to a worker queue before returning.

from __future__ import annotations

import hashlib
import sqlite3
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo


@dataclass(frozen=True)
class Subscription:
    account_id: str
    timezone_name: str
    local_send_time: time


SUBSCRIPTIONS = (
    Subscription("acct-us-1042", "America/New_York", time(8, 0)),
    Subscription("acct-eu-2087", "Europe/Berlin", time(8, 0)),
)


def delivery_key(account_id: str, local_day: date) -> str:
    raw = f"daily-report:{account_id}:{local_day.isoformat()}"
    return hashlib.sha256(raw.encode()).hexdigest()


def scheduled_utc(subscription: Subscription, local_day: date) -> datetime:
    zone = ZoneInfo(subscription.timezone_name)
    local_send = datetime.combine(local_day, subscription.local_send_time, zone)
    return local_send.astimezone(timezone.utc)


def tick(
    db: sqlite3.Connection, previous_tick: datetime, current_tick: datetime
) -> list[dict[str, str]]:
    jobs: list[dict[str, str]] = []
    for subscription in SUBSCRIPTIONS:
        zone = ZoneInfo(subscription.timezone_name)
        candidate_days = {
            previous_tick.astimezone(zone).date(),
            current_tick.astimezone(zone).date(),
        }
        for local_day in sorted(candidate_days):
            due_at = scheduled_utc(subscription, local_day)
            if not previous_tick < due_at <= current_tick:
                continue

            key = delivery_key(subscription.account_id, local_day)
            inserted = db.execute(
                "INSERT OR IGNORE INTO deliveries "
                "(delivery_key, account_id, local_day, created_at) VALUES (?, ?, ?, ?)",
                (key, subscription.account_id, local_day.isoformat(), current_tick.isoformat()),
            ).rowcount
            if inserted:
                jobs.append(
                    {
                        "delivery_key": key,
                        "account_id": subscription.account_id,
                        "local_day": local_day.isoformat(),
                    }
                )

    cutoff = (current_tick - timedelta(days=30)).isoformat()
    db.execute("DELETE FROM deliveries WHERE created_at < ?", (cutoff,))
    db.commit()
    return jobs


def main() -> None:
    db = sqlite3.connect(":memory:")
    db.execute(
        "CREATE TABLE deliveries ("
        "delivery_key TEXT PRIMARY KEY, account_id TEXT, "
        "local_day TEXT, created_at TEXT)"
    )
    now = datetime(2026, 3, 30, 6, 5, tzinfo=timezone.utc)
    jobs = tick(db, now - timedelta(minutes=10), now)
    print(jobs)
    print(tick(db, now - timedelta(minutes=10), now))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The second call prints an empty list. That tiny repeat is an idempotency eval, not merely a demo: it asserts that the ledger, rather than process memory, owns the delivery decision. Extend the fixture with dates on both sides of US and EU DST changes, a 25-hour gap that simulates a pause, and two workers attempting the same insert. Those cases are more valuable than another happy-path notebook cell.

There is one deliberate simplification. At 08:00 the wall time is normally unambiguous; a product that permits 02:30 needs a resolver for nonexistent and repeated times. Don't silently inherit whatever a datetime constructor happens to do.

This small Python probe verifies the scheduler connection without inventing a create payload. Set INFRAI_API_KEY and INFRAI_BASE_URL in the environment; the latter should be the documented API base. It lists existing cron tasks through the verified route and retries a rate limit without turning other HTTP failures into false success.

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


def list_cron_tasks() -> dict:
    url = os.environ["INFRAI_BASE_URL"].rstrip("/") + "/cron/list"
    headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}

    for attempt in range(5):
        request = Request(url, headers=headers, method="GET")
        try:
            with urlopen(request, timeout=30) as response:
                return json.load(response)
        except HTTPError as error:
            if error.code != 429 or attempt == 4:
                reason = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"Infrai request failed: {error.code} {reason}") from error
            retry_after = error.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)

    raise RuntimeError("Retry budget exhausted")


if __name__ == "__main__":
    print(json.dumps(list_cron_tasks(), indent=2))
Enter fullscreen mode Exit fullscreen mode

Retries, missed ticks, and work that outlives the callback

The handler should be quick. Infrai cron tasks can run for at most 900 seconds and target only a public http_url, so a report pipeline that may exceed that ceiling should use cron to trigger queue publication and let a worker consume the job. Push subscription targets must be public HTTPS. The useful attraction is concrete: Infrai puts scheduling and queue capabilities behind one REST API, one key, and one bill, so adding the worker handoff is another endpoint rather than another SDK integration. Its platform idempotency convention is a helpful second layer, but the domain ledger remains necessary because a report's identity is a local business date.

The retry contract has two layers. Publishing should carry the deterministic key, and consuming should atomically claim that same key before doing expensive report work. Standard queues are at-least-once, while FIFO deduplication covers only a five-minute window. A retry tomorrow still needs the database constraint. For an HTTP client, explicitly set the method, send bearer authentication from an environment variable, treat a 429 as a signal to back off, and honor Retry-After when it is present.

Now the awkward case: the UTC cron is paused across one or more send windows. Missed triggers are not automatically replayed. On recovery, query the ledger for each active subscription's latest delivered local date, enumerate missing eligible dates according to the product's retention policy, and enqueue them with their normal deterministic keys. This is reconciliation, not a special retry mode — and it is why last_tick = process start time is unsafe. Persist the evaluation watermark and move it only after the scan commits.

Keep payloads lean. A queue message is limited to 256KB, delayed delivery is capped at seven days, retention is at most 30 days, and acknowledged messages are deleted. Put an account ID, local date, report version, and delivery key in the job; rebuild the report from authoritative data. A queue is not an archive.

Which scheduler fits this retry and idempotency boundary?

The right comparison is about execution semantics, not the prettiest cron editor. These options solve different layers of the problem.

Option Good fit The catch Choose it when
Infrai cron plus queue Public HTTP trigger and worker handoff behind one REST contract No DAG orchestration or fan-out/join primitive; queue replay and multiple consumer groups are not Kafka-style The flow is a small tick-to-worker pipeline and the team values a broad, consistent API surface
Temporal Workflow orchestration More machinery than a single selector and idempotent worker may need The report is a durable multi-step workflow whose state and recovery deserve orchestration
Apache Airflow DAG-oriented scheduled processing A request-triggered email path is not automatically a data DAG Calendar-driven dependencies and operator-visible DAG runs are the central requirement
Apache Kafka Retained event streams and consumer groups It does not replace per-user timezone selection Replay or independent consumers are a first-class requirement
RabbitMQ Queue delivery, including documented priority queues Priority does not define daily eligibility or domain idempotency Existing RabbitMQ operations and priority dispatch matter more than a unified scheduling surface
Celery, BullMQ, or Sidekiq Application-owned worker handoff The application still owns timezone eligibility and the delivery ledger The team already operates the matching Python, Node.js, or Ruby worker stack

The catch is concrete: Infrai is not suitable when the cleanup and report pipeline needs DAG state, fan-out/join, private-only callbacks, Kafka-like replay, or multiple consumer groups. Stick with Temporal or Airflow for orchestration, Kafka for replayable streams, an existing RabbitMQ deployment when its queue semantics already match the operating model, or Celery, BullMQ, and Sidekiq when those workers are already part of the application stack. Conversely, none of those choices removes the need to model local report dates in application code.

An operational review should read like a chain of evidence. Verify that every subscription has a valid IANA zone; run timezone evals across both regional DST boundaries; assert that duplicate worker attempts produce one delivery; simulate a paused scheduler and reconcile missing local dates; keep cron work below 900 seconds by queuing long work; expose only authenticated public callbacks; alert on a watermark that stops advancing; and retain enough ledger history to explain a customer's last report. Also remember that cron run output retains only its first 4KB, so durable application records must carry the audit trail.

No exact-second SLA.

The practical decision is stable: UTC supplies the wake-up signal, the account timezone supplies the intended local date, and the ledger supplies truth. Once those roles are separate, DST, retries, cleanup, and vendor choice become testable rather than mysterious.

References

Top comments (0)