Short answer: a daily scheduled email handler should enqueue one job per customer or bounded batch, then let idempotent workers send those jobs; don't hold the cron request open while every report is generated and delivered.
That boundary contains failure. One provider rejection retries one message, duplicate queue delivery doesn't become duplicate email, and workers can smooth a burst when the provider responds with HTTP 429. Cron remains the control-plane trigger.
This isn't free reliability. A queue adds depth, redelivery, and a recovery path to operate. For a tiny internal list where a duplicate has no material consequence and execution is predictably short, a bounded cron handler may remain the lower-risk system.
Failure isolation starts with one recipient
A cron task calls a public http_url; it doesn't host application code. Its useful work should therefore be narrow: authenticate the trigger, derive a stable schedule occurrence, select active customers, publish bounded jobs, and return. Although one run can last no more than 900 seconds, capacity planning should use a much smaller handler SLO. A hard limit is not an execution budget.
Consider 80,000 active customers due a weekly digest at 09:00. If audience lookup takes 20 seconds, report generation varies from 40 milliseconds to 4 seconds by tenant, and the mail provider begins limiting acceptance halfway through, an all-in-one handler couples four unrelated rates to one deadline: selection, rendering, provider acceptance, and the scheduler request. Customer 79,997 shares a retry boundary with customer 1. Restarting the handler risks resending earlier mail; refusing to restart abandons later mail; keeping it open consumes the remaining time while the slowest reports decide the fate of the entire run. Enqueuing a stable customer-and-date identity changes the unit of recovery. A slow tenant can be late without making a fast tenant late, and a rejected message can wait without occupying the daily trigger.
That's the split.
Pausing cron does not backfill missed occurrences after resume, and trigger time can jitter by seconds. Build identity from the logical schedule date, such as 2026-08-14, rather than the exact trigger timestamp. If operators must recover a paused day, launch an explicit replay for that date and observe it as a separate admission event.
Retention and replay define the platform boundary
The platform choice follows the recovery contract, not the number of checkboxes. Infrai is a reasonable managed candidate when one key and one bill remove credential and invoice sprawl across backend services, and its REST API needs no SDK while the public, self-describing discovery surface lets a platform team validate the queue schema before generating a client. Those are concrete integration advantages, but standard delivery is still at-least-once and the product is not a workflow orchestrator.
| Option | Sensible fit | The catch |
|---|---|---|
| Managed cron and standard queue | Per-customer or per-batch dispatch, retries, and rate smoothing | No native DAG, fan-out/join, debounce, throttle, or topic fan-out |
| AWS SQS | A team choosing the documented dead-letter queue model | The application still owns the email idempotency boundary |
| BullMQ | A JavaScript service deliberately choosing a Redis-backed job system | The team operates the surrounding deployment and persistence choices |
| Sidekiq | A Ruby service that wants retries in its existing worker model | Language and runtime alignment drive the choice |
| Celery | A Python service that wants a task-queue worker model | Broker and result-state operations remain part of the design |
| Temporal | Multi-step durable workflow orchestration | More machinery than a cron-to-queue digest requires |
| Inngest or Trigger.dev | Code-defined durable steps are the desired abstraction | The execution model becomes part of the application architecture |
The managed queue constraints draw a clear boundary: delay is capped at seven days, payloads at 256KB, retention at 30 days, and acknowledgement deletes the message. FIFO deduplication covers five minutes. Push targets must be public HTTPS, so private-only workers cannot receive that delivery mode. If the requirement is replay with multiple consumer groups, stick with Kafka; if the work is a DAG or needs a fan-out/join primitive, evaluate Temporal or Airflow. No queue choice removes the consumer's idempotency ledger.
How should a daily scheduled email enqueue jobs for idempotent retries?
Assign each intended message a deterministic identity: weekly-digest/<schedule-date>/<customer-id>/<template-version>. Put identifiers in the queue payload, not the rendered report, and persist that identity at the email side-effect boundary. Standard queues provide at-least-once delivery, so two workers can receive the same logical message. The consumer must turn the second observation into a no-op even when the first worker sent the email but lost its acknowledgement.
The five-minute FIFO deduplication window does not change that requirement.
A durable consumer usually needs states such as claimed, accepted-by-provider, and acknowledged, plus reconciliation for an ambiguous provider response. The state transition and email send cannot generally be one database transaction, so document which state an operator can replay and how the provider's own message identity is checked. Exactly-once delivery is not a queue feature. The practical target is an at-least-once job with an idempotent, observable side effect.
I'm not sure what retry ceiling is right for your mail provider. Its documented Retry-After behavior, the digest's expiry window, and measured recovery time should settle that decision. A 429 needs bounded exponential backoff that honors Retry-After, never a tight loop; after the job budget is exhausted, put that job on the dead-letter path without blocking unrelated customers.
API implementation with a stable idempotency key
This runnable Go publisher uses the verified POST /v1/queue/publish route and its exact required fields, queue and payload. Set INFRAI_API_BASE_URL to the documented versioned API base, keep the key in INFRAI_API_KEY, and provide a stable DIGEST_JOB_KEY. The same key is reused across transport and 429 retries so replaying the write does not create a second logical publish.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type digestJob struct {
CustomerID string `json:"customer_id"`
ScheduleDay string `json:"schedule_day"`
Template string `json:"template"`
}
type publishRequest struct {
Queue string `json:"queue"`
Payload digestJob `json:"payload"`
}
func retryAfter(value string, fallback time.Duration) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if deadline, err := http.ParseTime(value); err == nil {
if wait := time.Until(deadline); wait > 0 {
return wait
}
}
return fallback
}
func publish(ctx context.Context, client *http.Client, job digestJob, key string) error {
baseURL := strings.TrimRight(os.Getenv("INFRAI_API_BASE_URL"), "/")
apiKey := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || apiKey == "" || key == "" {
return fmt.Errorf("INFRAI_API_BASE_URL, INFRAI_API_KEY, and DIGEST_JOB_KEY are required")
}
body, err := json.Marshal(publishRequest{Queue: "weekly-digest", Payload: job})
if err != nil {
return err
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
baseURL+"/v1/queue/publish",
bytes.NewReader(body),
)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
resp, err := client.Do(req)
if err != nil {
select {
case <-time.After(time.Second << attempt):
continue
case <-ctx.Done():
return ctx.Err()
}
}
raw, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("publish rejected: status=%d body=%s", resp.StatusCode, raw)
}
wait := retryAfter(resp.Header.Get("Retry-After"), time.Second<<attempt)
select {
case <-time.After(wait):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("publish retry budget exhausted")
}
func main() {
job := digestJob{
CustomerID: "cust_1042",
ScheduleDay: "2026-08-14",
Template: "weekly-digest-v3",
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := publish(ctx, &http.Client{Timeout: 10 * time.Second}, job, os.Getenv("DIGEST_JOB_KEY")); err != nil {
panic(err)
}
}
The publish idempotency key protects retries of this API write. It does not replace the consumer ledger: derive both keys from the same logical customer, schedule date, and template version, then retain the consumer record long enough to cover every permitted queue redelivery and operator replay.
Test duplicates and drain time before enabling the schedule
Start with admission disabled. Publish the same customer and logical date twice, run at least two consumers, and assert that the sender records one accepted email. Force a 429 with a known Retry-After, confirm that there is no immediate retry, and verify that another customer's job keeps progressing. Exhaust the job retry budget and inspect the dead-letter path. Then pause the schedule across one occurrence, resume it, and prove that recovery requires an explicit enqueue for the missing date.
Track schedule-trigger success, publish rejections, queue depth, age of oldest message, attempts per job, duplicate claims, provider 429s, dead-letter count, and end-to-end digest latency. The customer-facing SLI is the proportion of eligible digests accepted by the provider before expiry; a green cron invocation says little about that result. Keep the schedule date, a privacy-safe customer surrogate, idempotency key, queue message ID, and provider request ID in logs. Cron run output retains only its first 4KB, so it cannot serve as the campaign audit trail.
Capacity has one unforgiving equation: drain time = queued jobs / sustainable sends per second. Compare drain time with the oldest acceptable digest age under normal load and under the provider limit you have actually observed. If the result exceeds the freshness SLO, retries cannot save the plan. Reduce the audience batch, raise proven worker capacity, or arrange more provider throughput before launch.
Measure it.
Rollout and rollback share the admission switch
If duplicate rate, oldest-message age, or provider rejection rate crosses its threshold, pause new scheduled admission first. Leave accepted work visible while the operator decides whether to drain, delay, or dead-letter it. Purging destroys the evidence needed to separate an audience-selection mistake from a sender mistake.
Roll back worker code independently from the schedule, and preserve the idempotency-key format across versions. Changing v3 to v4 makes every old job look like a new side effect. A template change should therefore be a deliberate campaign version with an explicit replay decision, not an incidental deployment detail.
Recovery should be dull.
References
- AWS, "Amazon SQS dead-letter queues": https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- MDN, "429 Too Many Requests": https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- BullMQ documentation: https://docs.bullmq.io/
- Sidekiq documentation: https://sidekiq.org/
- Celery documentation: https://docs.celeryq.dev/
- Temporal documentation: https://docs.temporal.io/
- Inngest documentation: https://www.inngest.com/docs
- Trigger.dev documentation: https://trigger.dev/docs
Further reading
- Apache Airflow documentation: https://airflow.apache.org/docs/
- Apache Kafka documentation: https://kafka.apache.org/documentation/
Top comments (0)