A weekly customer digest has an awkward operational constraint: one logical send must happen once, even when its job is delivered more than once. Short answer: choose a queue that pushes jobs to an authenticated public HTTP worker, supports delayed retries and a dead-letter queue, and preserves a stable idempotency key; keep database polling when private networking or strict transaction coupling matters more than managed delivery.

For an e-commerce SaaS, the cron trigger is the easy part. The harder path begins after it selects active customers. A worker may time out after the email provider accepted a message, a rate limit may defer half the batch, or a deploy may overlap a redelivery. A simple queue is useful only if those cases have explicit outcomes that an eval harness can assert.

My choice for this workload is managed HTTP delivery over database polling. It keeps scheduling, delayed jobs, retry state, and exhausted work outside the application process while leaving the business handler as ordinary Python HTTP code. The catch is real: it requires a reachable endpoint and careful request authentication. If workers must remain on a private network, or enqueueing must commit in the exact same database transaction as an order update, stick with a transactional outbox plus polling.

How should a SaaS app queue HTTP workers, retries, delayed jobs, and cron?

Start with two records, not one. A schedule records when to plan a digest. A delivery records which customer and digest period to send. At the weekly boundary, the scheduler creates one delivery per eligible customer with a deterministic key such as digest:{customer_id}:{period_start}. The queue message carries that key, a delivery ID, and a schema version. It should not carry an ever-growing customer profile or generated email body.

That split prevents cron from becoming the unit of retry. If a run selects 18,000 customers and customer 417 is rate-limited, only that customer's delivery should wait. Re-running the entire weekly selection would create duplicate risk and make progress hard to see. Delayed jobs are therefore a delivery mechanism, not a second scheduler: cron creates the work; a delay controls when a specific attempt becomes eligible again.

Keep the public webhook thin. It authenticates the request, validates the schema, claims the idempotency key, performs the send, records a terminal result, and returns a status that matches the retry policy. The queue owns attempt timing. The application owns whether repeating the same logical command changes the world twice.

There are two useful idempotency layers. First, put a unique constraint on (customer_id, digest_period) so overlapping planner runs cannot create two logical deliveries. Second, store the provider result against the stable delivery ID before acknowledging success. Don't generate a fresh key on each attempt. That turns every retry into a new operation and defeats the whole design.

Exactly-once delivery is not the practical contract here. The practical contract is at-least-once transport plus an idempotent effect. A network timeout can hide whether the downstream side accepted a request, so the same command may need to be presented again. The handler must recognize it.

The focused Python handler

This example keeps the durable operations behind small interfaces so the same cases can run in a notebook, a unit-test fixture, or production. claim() must be an atomic insert-or-read operation backed by a unique key. complete() stores the accepted provider reference. The code deliberately returns success for an already completed delivery because redelivery has reached the desired state.

from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum from typing import Protocol class State(str, Enum): CLAIMED = "claimed" COMPLETE = "complete" @dataclass(frozen=True) class DigestJob: delivery_id: str customer_id: str period_start: str idempotency_key: str schema_version: int @dataclass(frozen=True) class Claim: state: State provider_reference: str | None = None class DeliveryStore(Protocol): def claim(self, job: DigestJob) -> Claim: ... def complete(self, delivery_id: str, provider_reference: str) -> None: ... class Mailer(Protocol): def send_digest(self, job: DigestJob, idempotency_key: str) -> str: ... def handle_digest( payload: dict, store: DeliveryStore, mailer: Mailer, ) -> tuple[int, dict]: job = DigestJob(**payload) expected_key = f"digest:{job.customer_id}:{job.period_start}" if job.schema_version != 1 or job.idempotency_key != expected_key: return 400, {"status": "invalid_job"} claim = store.claim(job) if claim.state == State.COMPLETE: return 200, { "status": "already_complete", "delivery_id": job.delivery_id, } provider_reference = mailer.send_digest( job, idempotency_key=job.idempotency_key, ) store.complete(job.delivery_id, provider_reference) return 200, { "status": "complete", "delivery_id": job.delivery_id, "completed_at": datetime.now(timezone.utc).isoformat(), } 

This is the happy path and the duplicate path, not a complete web framework. Authentication should happen before handle_digest, and logs should avoid the email address or rendered digest. The payload contains identifiers because identifiers are enough to load current policy and template data at execution time.

The subtle case is a process interruption between the provider accepting the message and complete() committing. The clean resolution depends on the provider supporting the same idempotency key. If it does, replaying send_digest returns the original logical result rather than sending twice. If it doesn't, I'm not sure a generic queue setting can close that ambiguity; the evidence needed is the email provider's documented duplicate-suppression contract. In that case, use a durable provider submission record and accept that the final boundary has an explicit uncertainty state for operator review.

Retry classes, DLQ policy, and regional boundaries

Retry policy should be a small classification table, not “retry every non-200 response.” HTTP 429 Too Many Requests means the client has sent too many requests in a period, and a response may include Retry-After. Honor that delay when present, add bounded jitter, and keep the original idempotency key. A connection timeout is also retryable because the result is unknown. Authentication failure and schema rejection are not improved by waiting.

Outcome Queue action Application record
200 already complete or complete Acknowledge Terminal success
429 with Retry-After Delay, then retry Attempt and next eligible time
Connection timeout Retry with bounded backoff Attempt with unknown remote result
400 invalid schema Send to DLQ Permanent validation failure
401 or 403 Stop automatic retries, alert Authentication failure
Attempt budget exhausted Send to DLQ Exhausted with last classification

A DLQ is quarantine, not storage and not a retry loop. AWS's SQS documentation, for example, describes a redrive policy with a maxReceiveCount that moves repeatedly unprocessed messages to a dead-letter queue. The general lesson is portable: keep the original payload, attempt metadata, and failure classification; inspect the cause; then redrive with the same logical identity after correction. Blindly moving every DLQ item back on a timer can recreate the same load and conceal a deterministic error. Keep the attempt budget tied to elapsed business value. A weekly digest delayed by minutes may still be useful, while one delayed beyond the next digest window probably is not. I wouldn't hard-code a universal retry count without the provider's rate-limit behavior, the digest expiry policy, and the team's on-call response time. Those inputs determine the cap. US and EU execution adds a separate boundary. Put each customer's delivery record, queue, worker, and DLQ in the region selected by the application's data policy; route the planner to that regional control plane rather than pulling all recipients into one global batch. The public endpoint can still be region-specific. Verify where payloads, logs, and backups reside before assuming the queue's region selector covers every downstream system.

Retries preserve identity.

Small payloads help here. They reduce the personal data crossing queue and log boundaries, make redrive inspection less sensitive, and avoid freezing prompt or merchandising data inside a delayed message. For an AI-generated summary, store the prompt version and model configuration with the delivery evaluation record, then generate under a token budget before the send step. That makes cost regressions visible without turning queue metadata into an experiment database.

What to measure before copying this choice?

The first eval is duplication. Enqueue the same (customer_id, digest_period) twice and assert one delivery row and one accepted provider operation. Then interrupt the handler after provider acceptance, redeliver the job, and assert the stable idempotency key produces the same logical result. This test is more valuable than a demo in which every request returns 200.

Measure queue age at the oldest ready job, attempt counts by failure class, time from cron fire to planner completion, delivery latency by region, DLQ depth, redrive outcomes, and duplicate-suppression hits. Track generated-token usage by prompt version if the digest contains an AI summary. Averages will hide a stuck tail, so use percentiles and explicit age thresholds.

Also run a rate-limit experiment: return 429 with a known Retry-After, then verify that the next attempt does not arrive early and retains the same delivery ID. Run a poison-message experiment with an unsupported schema version and verify that it reaches quarantine without consuming the full transient retry budget. Finally, redrive one corrected item and confirm its original identity and history remain queryable.

Choose managed HTTP delivery when those tests pass, the endpoint can be exposed and authenticated, and the team wants the queue to own retry timing. Choose database polling when private reachability, local transactional enqueue, or bespoke ordering is the dominant constraint. Neither choice removes idempotency work. It decides where the operational state lives.

Measure first. Copy second.

Sources