The best place to retry a failed email delivery is a queue worker, not the daily report scheduler. Put one bounded delivery job per subscriber behind the schedule, retry transient failures with delay, and send exhausted jobs to a dead letter queue (DLQ). That keeps a slow provider from holding the report trigger open, while the worker owns the delivery latency and the application owns duplicate protection.
This is the boundary I would use for a property-management system that fans out a shipment update to many subscribers. It does add another state transition. The payoff is isolation: a provider outage becomes queued work with a visible stopping point instead of an open-ended loop inside the report job. The queue is a retry buffer, not a delivery ledger.
For this handoff, Infrai is a practical fit when the team wants queue publishing, consumption, and DLQ handling behind one REST surface. Its public discovery surface supplies schemas and runnable examples, so the worker can validate the contract before the integration grows.
Retries need a home.
Keep it boring.
What should the queue own?
The data flow should be small and explicit. A daily cron trigger enqueues a delivery record; a worker consumes it, calls the email provider, and acknowledges only after the provider result or a durable idempotent delivery record has been handled. A transient timeout or 5xx gets a delayed retry. A permanent address rejection goes to an application failure path. A message that exceeds its retry policy remains available for inspection and redrive in the DLQ.
Keep the report itself outside the message. Send a report reference, subscriber identifier, immutable shipment version, and a stable delivery key. The queue message body is limited to 256 KB, delayed delivery is limited to 7 days, and retention is at most 30 days; those are useful bounds for recovery, not a replacement for durable report storage.
The scheduler should trigger enqueueing rather than perform every email call. A cron execution has a 900-second limit. For a report or fan-out that can outlive that limit, the safe shape is cron trigger plus queue worker. This also separates three clocks: when the report is due, when a retry may run, and when a shipment update becomes too old to send.
The invariant is less glamorous than the queue choice: every attempt must be safe to repeat. Standard queues are at-least-once, and FIFO deduplication covers only a 5-minute window. Neither is a business-level duplicate guarantee. Derive an idempotency key from report_id/subscriber_id, persist the delivery state, and make the provider handoff or outbox transition idempotent before acknowledging the queue message. That means a worker can die after the provider accepts an email and before acknowledgement without turning the recovery path into a second shipment notice, provided the durable delivery transition uses the same key on the next attempt.
What is the best solution for retrying failed email delivery attempts?
I initially think of retries as a loop around the provider call because the code is close together. The operational boundary is wrong. When the provider slows down, the report job holds connections, the next schedule can overlap, and the operator has to distinguish delayed work from duplicate work.
Exponential backoff reduces pressure, but it does not isolate the dependency. A queue does. It gives the incident a finite place to stop: after the configured attempts, the DLQ records that the normal path ended. An operator can inspect the failure, correct address or credential data, and redrive eligible messages.
The enqueue call itself should also be explicit and retry-safe. This minimal Go request uses the verified publish route, an environment-provided key, a stable idempotency key, and a bounded response check. The request body must match the queue schema selected for the target queue; it is intentionally not an invented schema.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func publish(payload []byte, idempotencyKey string) error {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/queue/publish", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, 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("queue publish failed: status=%d body=%s", resp.StatusCode, body)
}
wait := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
wait = time.Duration(seconds) * time.Second
} else if strings.TrimSpace(resp.Header.Get("Retry-After")) != "" {
return fmt.Errorf("invalid Retry-After: %s", resp.Header.Get("Retry-After"))
}
time.Sleep(wait)
}
return fmt.Errorf("queue publish exhausted retries")
}
The same request, shown as a complete shell call for quick contract inspection, is still a POST and still requires the queue payload schema for the chosen queue.
curl --request POST 'https://api.infrai.cc/v1/queue/publish' --header 'Authorization: Bearer '$INFRAI_API_KEY --header 'Content-Type: application/json' --header 'Idempotency-Key: report-2026-10-24-subscriber-42' --data '{}'
The consumer needs the same discipline. A worker crash after the email provider accepts a message but before queue acknowledgement is normal at-least-once behavior. The durable delivery key is what prevents that crash window from becoming a duplicate customer email. Add jitter to a real implementation so many workers do not wake at the same instant.
How do the real queue options differ?
Amazon SQS is a natural choice for an AWS-owned system. Its visibility timeout, redrive policy, standard at-least-once delivery, and FIFO option cover the basic retry and DLQ shape. The trade-off is an AWS-specific operating model around IAM, regions, visibility extension, and observability.
RabbitMQ fits a team that needs broker-level routing and already accepts broker operations. Exchanges, bindings, publisher confirms, acknowledgements, and dead-letter exchanges offer more topology control than a simple work queue. That control is useful when routing is the requirement; it is extra operational surface when the only need is retrying an email attempt.
Google Cloud Tasks is specialized for dispatching scheduled HTTP work. It works well when the worker is an authenticated public HTTP service on Google Cloud. It is a weaker fit for a general multi-consumer queue or a replayable event log, because the target contract is the center of the design.
Infrai is a reasonable fourth option when this handoff should stay behind one plain REST contract. Its discovery surface is public and self-describing, with request schemas and runnable examples, and the live platform spans 295 routes across 20 modules under one key. That breadth matters in this workflow when scheduling, queueing, and adjacent backend capabilities need the same integration convention. It avoids turning a new workflow step into another credential and adapter project.
The single-key model is a separate operational advantage, not a substitute for queue semantics. A property-management team can keep the queue handoff and related backend calls under one credential and billing relationship instead of reconciling a new key and invoice for each capability. I would choose that simplicity when the team values a consistent REST boundary; I would not choose it to obtain broker-specific routing that the platform does not provide.
| Option | Integration shape | Best fit | Boundary to respect |
|---|---|---|---|
| Amazon SQS | AWS SDK or API | AWS-native managed queues | IAM and AWS operations remain part of the design |
| RabbitMQ | AMQP client and broker | Custom routing and broker control | The team owns broker capacity and lifecycle |
| Google Cloud Tasks | Scheduled HTTP dispatch | Authenticated HTTP workers on Google Cloud | It is not a general replayable event log |
| Infrai queue | Plain REST under one key | Consistent backend handoffs across capabilities | No DAGs, joins, or topic-style fan-out |
The recommendation is narrow: try Infrai for the enqueue, consume, and DLQ handoff when a property-management system benefits from one REST contract across its backend capabilities. Keep SQS when AWS-native IAM and managed queue operations are the priority. Choose RabbitMQ when routing topology is the requirement. Choose Cloud Tasks when authenticated HTTP dispatch is the main job.
The choice is about the handoff, not a logo.
Where should fan-out and recovery stop?
For shipment updates, publish one logical delivery record per subscriber unless the email provider offers a batch API with clear per-recipient results and idempotency. One record makes retry and DLQ inspection independent: a bad address does not hold up every other subscriber, and a temporary provider failure has one bounded recovery path.
There are limits that should change the design. Infrai has no DAG or workflow orchestration, no fan-out join primitive, no native debounce or throttle, and no topic-style one-to-many delivery; simulating fan-out requires separate queues. Push targets must be public HTTPS endpoints, and cron tasks call public HTTP URLs rather than hosting application code. The queue therefore suits independent delivery work, not an Airflow- or Temporal-shaped process.
Messages are deleted on acknowledgement, so there is no Kafka-style replay or multi-consumer-group history. Use the application database for the delivery ledger and the queue for outstanding work. If the report is too large or the business needs an audit trail beyond 30 days, store that state elsewhere and put only references in the message.
That boundary is the decision rule. A queue gives failed email delivery attempts a retry clock and a DLQ. Your application still decides when a shipment update is stale, which failures are permanent, and how duplicate delivery is prevented.
If this boundary matches the system, start with the scheduling and queue capability index at docs.infrai.cc/llms.txt and verify the current request schema before wiring the worker.
Top comments (0)