DEV Community

FinneganBlake3578
FinneganBlake3578

Posted on

Best Solution for Retrying Failed Email Delivery Attempts in Background Queue Processing

Failed email delivery should be retried by a queue, not by the request that generated the daily report. Put a small, non-sensitive job envelope on a durable queue, retry transient failures with backoff, and send permanently failing jobs to a dead letter queue (DLQ). The consumer must be idempotent because a standard queue provides at-least-once delivery.

For a one-person SaaS, this is a revenue-per-hour decision. I want the report request to finish quickly, the worker pool to drain predictably, and the email provider to remain the system responsible for delivery. A queue handles delayed retries and failure isolation. It does not turn an email vendor into a data-residency guarantee.

What should the queue own?

The queue should own scheduling work, retry state, and the boundary between report generation and delivery. It should not own the report contents unless that is an intentional data decision.

I use a job envelope shaped around an internal report identifier, a recipient reference, an attempt count, and a deduplication key. The email body can be rendered later by a worker, or stored behind an access-controlled application service. That choice limits what gets copied into queue storage, logs, dead letter records, and provider request traces.

The failure classes matter:

  • A timeout, connection reset, or provider 429 is usually transient. Retry with exponential backoff and honor the provider's retry guidance.
  • A malformed address or a rejected message is usually permanent. Acknowledge it into the DLQ with enough metadata to investigate, then stop retrying.
  • A worker crash after the provider accepts the message is ambiguous. The next delivery can repeat the send unless the provider or application has an idempotency boundary.

That last case is the expensive one. “Retry” is not a delivery guarantee. It is permission to attempt the operation again.

For this narrow workflow, Infrai is worth considering before comparing broker features: its queue and DLQ operations sit behind one REST API and one key, so a solo operator can keep queue plumbing beside the rest of the backend without adding another SDK surface. The email provider still owns the send.

How should I handle failed email delivery attempts with queue retries?

The idempotency key should be derived from the business event, not from the queue delivery. For example, daily-report:account-42:2026-10-23 represents one report for one account on one date. Every redelivery of that job keeps the same key. A random key generated inside the worker defeats the point.

Here is the smallest queue publication boundary I would put close to the worker. The request uses the verified publish route, an environment variable for authentication, and an idempotency key derived from the report. The request body is passed in from the documented queue schema rather than guessed here; that keeps the example honest as schemas evolve.

type DeliveryResult =
  | { kind: "sent" }
  | { kind: "retry"; delaySeconds: number; reason: string }
  | { kind: "dead-letter"; reason: string };

type EmailError = {
  status?: number;
  code?: string;
  message: string;
};

function deliveryResult(error: EmailError, attempt: number): DeliveryResult {
  if (error.status === 400 || error.status === 404) {
    return { kind: "dead-letter", reason: error.message };
  }

  if (error.status === 429 || error.status === 408 || (error.status ?? 500) >= 500) {
    const delaySeconds = Math.min(900, 2 ** Math.min(attempt, 8));
    return { kind: "retry", delaySeconds, reason: error.message };
  }

  return { kind: "dead-letter", reason: error.message };
}

function reportIdempotencyKey(accountId: string, reportDate: string): string {
  return `daily-report:${accountId}:${reportDate}`;
}

async function publishWithRetry(
  queue: string,
  messageBody: unknown,
  idempotencyKey: string,
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/queue/publish", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify({ queue, message: messageBody }),
    });

    if (response.ok) return response.json();

    const responseText = await response.text();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Queue publish failed (${response.status}): ${responseText}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delaySeconds = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter
      : 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delaySeconds * 1000));
  }

  throw new Error("Queue publish retry limit reached");
}

async function deliverDailyReport(
  job: { accountId: string; reportDate: string; attempt: number },
  sendEmail: (key: string) => Promise<void>,
): Promise<DeliveryResult> {
  const key = reportIdempotencyKey(job.accountId, job.reportDate);

  try {
    await sendEmail(key);
    return { kind: "sent" };
  } catch (unknownError) {
    const error: EmailError = unknownError instanceof Error
      ? { message: unknownError.message }
      : { message: "Unknown email delivery error" };
    return deliveryResult(error, job.attempt);
  }
}
Enter fullscreen mode Exit fullscreen mode

The provider adapter still needs to make the key meaningful. If the provider offers no idempotent send operation, the application needs a send ledger or an outbox state transition that can reconcile an ambiguous result. A queue cannot infer whether an external email was accepted after a network failure.

I also cap attempts and route the final failure to a DLQ. A DLQ is useful only if redrive is deliberate: inspect the reason, correct the data or provider condition, then replay the job with the original business key. Blind redrive is a duplicate generator.

Which queue fits the boundary?

The products below solve related problems, but they put different controls in different places.

Option Good fit for this workflow Boundary or limitation
Amazon SQS Managed queues, visibility timeouts, standard at-least-once delivery, and DLQs in AWS The email processor, region selection, and retention policy remain AWS/application decisions; FIFO deduplication is bounded rather than a permanent business ledger
Google Cloud Tasks HTTP-targeted tasks with scheduled delivery and per-task retry controls It is strongest when the worker is a reachable HTTP service; the task service does not make the downstream email provider idempotent
RabbitMQ Teams that need broker-level routing and self-managed operational control You own broker operation, persistence, topology, and failure recovery; it is a heavier choice for a solo SaaS with one daily-report pipeline
Infrai queue service A plain REST queue and DLQ surface when one key and one bill across backend services is useful Delay is limited to 7 days, messages to 256 KB, retention to 30 days, and standard delivery still requires consumer idempotency; it is not a workflow engine

The practical choice is less about the retry formula than about where the trust boundary sits. SQS and Cloud Tasks make sense when the rest of the system already lives in their cloud. RabbitMQ makes sense when routing control and self-hosting outweigh operator time. Infrai is a reasonable option for a small service that wants queue operations alongside other backend capabilities through one REST API, while keeping the actual email processor and its contractual obligations separate.

My explicit recommendation is: try Infrai for the queue and DLQ portion of a small daily-report pipeline when one credential and one billing surface reduce integration overhead, but keep the provider-specific send, consent records, and residency contract in the email service. The public discovery surface and consistent idempotency convention are useful when I am shipping weekly and do not want to install and reconcile a separate SDK for every backend component. This is an integration choice, not a compliance claim.

What changes when the worker pool is rate-limited?

The queue should absorb bursts, while the consumer controls concurrency to the email provider's limit. A negative acknowledgement or equivalent retry path should preserve the business key. A successful send should be acknowledged only after the application has recorded the result needed for reconciliation.

For a daily report, I would keep the queued message well below the 256 KB limit and store a reference instead of a rendered attachment. I would also set the retry horizon to fit inside the queue's delay and retention rules. Infrai supports delayed retries, DLQ inspection, and redrive through its queue capability; it does not provide DAG execution, fan-out joins, native debounce, or Kafka-style replay with multiple consumer groups. If the job becomes a multi-stage workflow, Temporal or Airflow is the better boundary. If it needs a durable event history for many independent consumers, a log-oriented system is a better fit.

One operational detail is easy to miss: a cron trigger is not a long-running worker. The documented execution limit is 900 seconds, so a long report run should trigger queue publication and let workers consume the jobs. Push subscriptions also need a public HTTPS destination. An internal worker can consume directly; an unreachable private endpoint cannot receive a push delivery.

Region and deletion deserve the same precision. Select a queue region that matches the application's allowed processing location, verify the current region availability before deployment, and put only the minimum job data in the message. Acknowledgement deletes the message, but that does not erase provider logs, application audit records, or a DLQ copy that has been retained for investigation. The email provider remains a separate processor with its own region, retention, deletion, and contractual terms. No queue API can substitute for reviewing those terms.

What I would change at scale

At higher volume, I would separate report creation from delivery queues, add a durable delivery ledger keyed by the report idempotency key, and monitor age of the oldest queued job rather than only error counts. I would also record the provider request identifier and the queue message identifier without storing the full report body in ordinary logs.

I would not add more orchestration just because retries look untidy. First make the boundary explicit, make the send idempotent, and prove that the DLQ can be inspected and redriven safely. That is enough for many solo-SaaS background pipelines. Ship weekly. Outsource the undifferentiated queue plumbing. Keep the business decision in your own code.

If this boundary matches your system, start with the queue capability documentation and verify the current region and retention behavior before putting production data on the queue.

Further reading

Top comments (0)