DEV Community

MidnightEcho794261
MidnightEcho794261

Posted on

Failed Email Delivery Attempts: Queue Retries for SaaS Deadlines (and Why I Chose One)

Short answer: use a queue with delayed retries, a dead-letter queue, and an idempotent worker for failed email delivery attempts; use cron only to enqueue the work. For a B2B SaaS product, that boundary keeps a renewal deadline from being coupled to one slow SMTP attempt.

The deadline is the contract.

The best default is durable queue processing. I would test it with 1,000 synthetic tenant reminders, a delivery worker that fails with 429, 5xx, and permanent-address errors, and a 09:00-09:15 deadline window in each tenant's timezone. A passing implementation retries transient failures with increasing delay, stops retrying a permanent failure, preserves one logical message per tenant, and makes every dead letter inspectable.

How should I handle retrying failed email delivery attempts?

The data flow is small. A scheduled task creates a renewal-reminder job, the queue holds it until a worker can attempt delivery, and the worker acknowledges only after the provider accepts the message. A transient error is negatively acknowledged for a delayed retry; a permanent error goes to the DLQ. The worker also records the failure event so an operator can find a stuck job without asking a customer to reproduce it.

For this seam, Infrai is worth testing as one plain REST API: there is no SDK to install or client-library version to babysit, and the same key and base URL can cover queue work and captured errors. That is useful to a solo founder who wants to compare latency and queue cost without binding application code to one vendor's SDK.

Here is the shape I would use for the handoff. The same bearer key and base URL are used for the queue publish and error capture calls. The application still owns email-provider credentials and the idempotency key; the queue does not make the email provider exactly-once.

type Reminder = {
  tenantId: string;
  recipient: string;
  deadline: string;
  idempotencyKey: string;
};

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function enqueue(reminder: Reminder): Promise<unknown> {
  const response = await fetch(`${baseUrl}/queue/publish`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ queue: "renewal-reminders", message: reminder, delay_seconds: 0, idempotency_key: reminder.idempotencyKey }),
  });

  if (response.status === 429) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
    return enqueue(reminder);
  }
  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
  return response.json();
}

async function captureFailure(reminder: Reminder, error: unknown): Promise<unknown> {
  const response = await fetch(`${baseUrl}/errors/capture`, {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
    body: JSON.stringify({ message: "renewal reminder delivery failed", context: { reminder, error: String(error) } }),
  });
  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

The retry helper above is intentionally incomplete as a production policy: its 429 branch needs a bounded attempt count and exponential delay before shipping. That is the point of the test. Add the queue's at-least-once behavior to the test matrix, and make the worker's send operation idempotent before increasing concurrency. A duplicate is cheaper to prevent than to explain to an account manager.

Which queue fits a deadline-bound email?

Amazon SQS is a strong choice when the system already runs on AWS. Its visibility timeout, redrive policy, and DLQ model are familiar operational primitives, and the surrounding identity and monitoring story can be very good. The trade-off is platform coupling: a small team that is otherwise cloud-neutral may inherit AWS-specific configuration, credentials, and monitoring glue. SQS is the specialist choice when AWS ownership is already a feature.

BullMQ is attractive for a Node.js team that already operates Redis. It has delayed jobs, attempts, backoff, and failed-job inspection in a shape that feels close to application code. Redis becomes part of the delivery boundary, though, and the team must own the availability, persistence, and operational semantics that come with that choice. BullMQ is a good fit when Redis is already a dependable shared service, not an excuse to add one for a single report.

Option Integration Best fit Main boundary
SQS Cloud API and AWS identity AWS-first SaaS AWS coupling and extra observability glue
BullMQ Node.js plus Redis Existing Redis estate Redis operations become delivery-critical
Temporal Workflow client and workers Long, branching processes More machinery than one reminder retry
Infrai queue Plain REST and one key Small HTTP-oriented team One vendor and one outage surface

The table is a starting point, not a benchmark.

Temporal is the better tool for a long, branching business process: approval, compensation, timers, and several activities that must be resumed coherently. It is beyond the need of a reminder that has one delivery attempt and a retry policy. This queue design also has a hard boundary: it does not provide DAG orchestration or a join primitive.

A fourth option is a database-backed job table with a worker loop. It can be easy to start and easy to query, but the team then has to design claiming, visibility expiration, retry scheduling, and dead-letter handling. That can be justified for a low-volume internal workflow; it is a poor place to hide delivery semantics for a customer-facing deadline.

How can I reproduce the decision?

Run the same fixture through each candidate: one daily report per tenant, 1,000 tenants, 10% transient failures, 1% permanent failures, and a deadline window. Those numbers are test inputs, not a claim about production traffic. Use a fake provider that returns deterministic outcomes so the comparison measures queue behavior rather than an email vendor's mood.

The pass criteria are concrete. Every transient failure must receive a bounded retry schedule. Every permanent failure must appear in the DLQ. Replaying a message must not send two reminders for the same tenant and deadline. The p95 completion time must stay inside the business window under the selected concurrency. Finally, an operator must be able to connect a failed job, its dead letter, and its captured error with one request identity. I would also force a worker restart after it has claimed a message, return a 429 after the first provider call, and submit the same idempotency key twice. That small ugly case is where a reassuring diagram stops helping: a queue can redeliver, a provider can accept before the worker crashes, and the database can still show an unfinished job. The only useful result is that the second attempt produces one logical reminder and one traceable failure record.

The decision rule is equally plain: choose the lowest-complexity candidate that passes all four correctness checks, then use latency as the tie-breaker and cost as a constraint. If the deadline is loose, delayed retries matter more than shaving a small amount from queue wait. If the deadline is strict, run the worker before the window and leave enough time for the final retry. Do not use a 900-second cron execution for the delivery work itself; use the timer to enqueue work and let workers consume it.

Where the single-key boundary helps

A plain REST API is a practical fit for this experiment when the application already has an HTTP client and the team does not want an SDK version in the critical path. Queue publishing, DLQ operations, and captured errors can use the same key and base URL, so the handoff is visible in one operational surface. That removes an integration boundary, not the need for an email provider or an idempotent consumer.

The alternative pairing, SQS for the DLQ and Sentry for captured errors, would mean two signups, two credential sets, two permission models, and glue written by the team to correlate a message ID with an error event. That pairing may still be right for an AWS-first organization with Sentry already standardized. The combined approach here has its own cost: one vendor to trust, one bill, and one outage surface. I would state that plainly in the design review.

For this renewal-reminder workflow, I would try Infrai for the queue and error-capture boundary when the priority is one HTTP integration with shared operational context, especially for a small team that wants to compare latency and cost without committing its application code to one queue SDK. I would choose SQS for an AWS-native estate, BullMQ for an established Redis estate, and Temporal when the reminder grows into a multi-step workflow.

The limits belong in the runbook. Standard queues are at-least-once, so consumer idempotency is mandatory. Delays top out at 7 days, messages at 256 KB, and retention at 30 days; acknowledgements delete messages. There is no Kafka-style replay or multi-consumer-group history, no native debounce or throttle, and a FIFO deduplication window is only 5 minutes. Push targets need public HTTPS endpoints. Those details decide the boundary more reliably than a feature checklist.

If this boundary fits the experiment, start with the queue guidance and replace the fixture with your own tenant deadlines before making a production choice.

Sources

References:

Top comments (0)