rust 117 lines · 3 tabs

Retry-Aware Email Queue With a Tokio mpsc Worker and Exponential Backoff

Shared by codesnips Aug 2026
3 tabs
use tokio::sync::mpsc::{self, Sender};
use tokio::sync::mpsc::error::TrySendError;

use crate::email::{EmailJob, EmailMessage};
use crate::worker::EmailWorker;

#[derive(Debug)]
pub enum QueueError {
    Full,
    Closed,
}

#[derive(Clone)]
pub struct EmailQueue {
    tx: Sender<EmailJob>,
}

impl EmailQueue {
    pub fn start(capacity: usize) -> Self {
        let (tx, rx) = mpsc::channel(capacity);
        let worker = EmailWorker::new(rx, tx.clone());
        tokio::spawn(worker.run());
        EmailQueue { tx }
    }

    pub fn enqueue(&self, message: EmailMessage) -> Result<(), QueueError> {
        let job = EmailJob::new(message);
        self.tx.try_send(job).map_err(|e| match e {
            TrySendError::Full(_) => QueueError::Full,
            TrySendError::Closed(_) => QueueError::Closed,
        })
    }
}
3 files · rust Explain with highlit

This snippet shows a small but realistic asynchronous email queue built on tokio, split into the message type and job wrapper, the background worker that drains a channel, and the client-facing queue handle. The design separates the producer side (anything that wants to send mail) from the consumer side (a single worker task) using an mpsc channel, which is the idiomatic way to fan work into a dedicated processor without sharing mutable state.

In email.rs, EmailMessage is the plain payload, while EmailJob wraps it with retry bookkeeping: an attempt counter and a max_attempts ceiling. The backoff method computes an exponential delay (base * 2^attempt) capped at 30 seconds, which spreads out retries so a flaky SMTP endpoint isn't hammered. Keeping the retry policy on the job itself means the worker stays generic and the message carries its own fate.

In worker.rs, EmailWorker::run loops with while let Some(job) = self.rx.recv().await, so it processes jobs one at a time and exits cleanly when every sender is dropped. On failure it inspects job.can_retry(); if attempts remain it calls job.next_attempt(), sleeps for backoff() via tokio::time::sleep, and re-enqueues through a cloned Sender. Re-queuing rather than blocking the worker on a sleep in-line is a deliberate trade-off — here the code sleeps before re-sending so ordering is simple, but the Sender clone shows how a job can be pushed back for later. Exhausted jobs are logged and dropped, which acts as a crude dead-letter path.

In queue.rs, EmailQueue owns the Sender and spawns the worker with tokio::spawn, returning a cheap, clonable handle. enqueue wraps a message in a fresh EmailJob and uses try_send so a full bounded channel surfaces backpressure as QueueError::Full instead of silently blocking the caller.

The key ideas worth internalizing: bounded channels give backpressure, dropping all senders is the natural shutdown signal, and attaching retry state to each job keeps the worker loop simple. A pitfall to note is that re-enqueueing after a sleep can reorder jobs and, with a bounded channel, a retry send can itself fail — production code would route those to a persistent store rather than a log line.


Related snips

Share this code

Here's the card — post it anywhere.

Retry-Aware Email Queue With a Tokio mpsc Worker and Exponential Backoff — share card
Link copied