Email Queueing and Retry Architecture for Transactional Systems
Design the send queue behind transactional email: rate limiting, exponential backoff, idempotency keys, dead-letter handling, and priority lanes that keep password resets fast.
The moment your application calls an ESP API inline with a user request, you have coupled a password reset to a third party's availability. This section covers the layer that decouples them: the queue that accepts a send intent, the worker that dispatches it, the retry policy that survives a provider blip, and the dead-letter path that stops a poisoned message from consuming a worker forever.
The requirements are unusual compared with an ordinary job queue. A duplicate HTTP request is invisible to a user; a duplicate receipt is a support ticket. A delayed analytics job is fine; a password reset delayed by ten minutes is a failed login. And unlike most work, sends are rate-limited by an external party whose limits change without warning. Those three properties — visible duplicates, latency sensitivity, and external throttling — shape everything below.
Why the Send Cannot Happen Inline
An inline await esp.send(...) inside a request handler fails in four distinct ways, and only one of them is obvious.
The obvious one is provider downtime: the API is unreachable, the call throws, and the user sees an error for an action that otherwise succeeded. The second is latency coupling — a provider having a slow minute turns your signup endpoint into a slow endpoint, and connection pools fill with requests waiting on someone else's infrastructure. The third is throttling: providers enforce per-second and per-day limits, and an inline call has nowhere to put a message it is not allowed to send yet, so it drops it. The fourth is the subtle one: partial success. The provider accepted the message and your HTTP client timed out waiting for the response, so your code retries and sends it twice.
The enqueue itself must be durable before the request returns. Writing the send intent to the same database transaction that created the underlying record — the transactional outbox pattern — removes the last inconsistency: either the user was created and the welcome email was queued, or neither happened.
Core Implementation: A Worker With Rate Limiting and Bounded Retry
The worker is where the interesting constraints live. It must respect a rate limit it did not set, back off when the provider says to, distinguish retryable failures from permanent ones, and never send the same message twice.
# sender_worker.py — dispatch queued sends under an external rate limit with a
# bounded, jittered retry policy.
import random
import time
from dataclasses import dataclass
import redis
r = redis.Redis()
# The provider's documented ceiling, minus headroom for any other process that
# shares the account. Exceeding it costs 429s and, eventually, throttled sending.
RATE_PER_SECOND = 12
MAX_ATTEMPTS = 6
# Failures worth retrying: transport problems and provider-side transients.
RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
@dataclass
class SendJob:
idempotency_key: str # derived from the business event, stable across retries
to: str
template_id: str
payload: dict
attempt: int = 0
def acquire_slot(bucket: str = "esp:tokens") -> bool:
"""Sliding-window limiter. Returns False when the current second is full."""
now = int(time.time())
key = f"{bucket}:{now}"
used = r.incr(key)
if used == 1:
r.expire(key, 2) # keys age out on their own; no cleanup job
return used <= RATE_PER_SECOND
def backoff_seconds(attempt: int, retry_after: float | None) -> float:
"""Honour the provider's Retry-After when present, else exponential + jitter."""
if retry_after is not None:
return retry_after # the provider knows better than our formula
base = min(2 ** attempt, 300) # cap at five minutes
# Full jitter: without it, every worker retries in lockstep and re-storms
# the provider at exactly the same instant.
return random.uniform(0, base)
def handle(job: SendJob, esp, queue) -> None:
if not acquire_slot():
# Not a failure — we are simply early. Requeue without burning an attempt.
queue.requeue(job, delay=1.0)
return
try:
# The idempotency key is what makes a redelivered job safe: the provider
# recognises the repeat and returns the original result.
esp.send(
to=job.to,
template_id=job.template_id,
payload=job.payload,
idempotency_key=job.idempotency_key,
)
except esp.HttpError as exc:
if exc.status not in RETRYABLE_STATUS:
# 400-class errors are deterministic: a malformed address, a template
# that no longer exists. Retrying cannot change the outcome.
queue.dead_letter(job, reason=f"permanent {exc.status}: {exc.message}")
return
job.attempt += 1
if job.attempt >= MAX_ATTEMPTS:
queue.dead_letter(job, reason=f"exhausted after {MAX_ATTEMPTS} attempts")
return
queue.requeue(job, delay=backoff_seconds(job.attempt, exc.retry_after))
return
queue.ack(job)
Three details carry most of the value. Requeue without incrementing the attempt count when the limiter denies a slot — being early is not a failure, and counting it would exhaust the retry budget on a busy afternoon. Full jitter rather than a fixed exponential delay, because synchronised workers retrying at the same instant reproduce the exact load spike that caused the throttling. And honour Retry-After: when a provider tells you when to come back, that number reflects state you cannot see.
Second Pattern: Priority Lanes
Not all transactional mail is equally urgent. A password reset is worthless if it arrives after the user has given up; a monthly usage summary can wait an hour. A single FIFO queue makes the slow, high-volume traffic delay the fast, low-volume traffic — exactly backwards.
Implement lanes with reserved worker capacity rather than strict priority. A pool where, say, 70% of workers poll the interactive lane first and 30% poll bulk first guarantees both drain: the interactive lane cannot be starved by volume, and the bulk lane cannot be starved by a sustained trickle of urgent mail. The rate limiter stays global, because the provider's ceiling applies to the account, not to a lane.
Dead Letters and Poison Messages
A message that fails deterministically will fail on every retry, and without a terminal state it circulates forever, consuming both worker capacity and rate-limit slots that real sends need. The dead-letter queue is what makes failure terminal.
What belongs there: permanent provider rejections (a malformed address, a deleted template), messages that exhausted the retry budget, and anything that threw an unhandled exception in the worker itself. What does not: throttling responses, which are not failures at all, and any 4xx that a documented Retry-After accompanies.
The dead-letter queue is only valuable if someone looks at it. Alert on non-zero depth rather than on a threshold, because a healthy pipeline produces zero dead letters and any entry represents a real message a real user never received. Store the full job payload alongside the failure reason so a fixed message can be replayed rather than reconstructed.
| Failure | Class | Worker action | Alert |
|---|---|---|---|
429 with Retry-After |
Throttling | Requeue at the stated delay, attempt count unchanged | No |
503 from the provider |
Transient | Exponential backoff with jitter | Only if sustained |
| Connection timeout | Ambiguous | Retry with the same idempotency key | Only if sustained |
400 malformed address |
Permanent | Dead-letter immediately | Yes |
404 unknown template |
Permanent | Dead-letter immediately | Yes, page — a deploy broke |
| Retry budget exhausted | Terminal | Dead-letter with the full attempt history | Yes |
Pipeline Integration Steps
- Write the send intent inside the originating transaction, so a queued message and the record that justifies it commit together.
- Derive the idempotency key from the business event — the order ID, the reset token — never from a random value generated per attempt, or every retry looks like new work to the provider.
- Split lanes by latency requirement, not by message type: anything a human is actively waiting for belongs in the interactive lane.
- Set the limiter below the provider's documented ceiling to leave headroom for other processes sharing the account.
- Cap the retry budget at a duration matched to the message's usefulness — a reset token that expires in fifteen minutes should not be retried for an hour.
- Dead-letter with the full payload and alert on any non-zero depth.
- Emit the outcome into the same event store that consumes webhook delivery events, so a message's life is queryable from enqueue to bounce in one place.
Debugging: Named Symptoms, Cause, and Fix
Symptom: queue depth grows steadily during business hours and drains overnight. Cause: the limiter is set below your actual arrival rate, so you are shipping less mail per second than you generate. Fix: compare peak arrival rate against RATE_PER_SECOND; if the provider's ceiling is genuinely the constraint, request a limit increase rather than adding workers, which will only produce more 429s.
Symptom: a burst of 429s every time the queue recovers from a pause. Cause: retries synchronised by a fixed backoff, all firing at the same instant. Fix: apply full jitter, as in the worker above — the randomised delay spreads the recovery over the whole window.
Symptom: customers report receiving the same receipt twice, minutes apart. Cause: a timeout after the provider accepted the message, followed by a retry with a fresh idempotency key. Fix: derive the key from the business event and verify it is stable across attempts; the ESP integration layer is where that guarantee belongs.
Symptom: the dead-letter queue fills with 404 template errors after a deploy. Cause: a template was renamed or removed while messages referencing it were still queued. Fix: treat template IDs as an API contract — deprecate rather than delete, and drain the queue before removing an ID from the provider.
Symptom: password resets arrive ten minutes late, but only on some days. Cause: a bulk job sharing the queue. Fix: the priority lanes above, with reserved capacity rather than strict weighting.
Validation and Deployment Checklist
Observability: The Four Numbers That Describe a Send Pipeline
A queue that is working and a queue that is quietly failing look identical from the application's side — both accept messages without error. Four metrics distinguish them, and a pipeline that publishes all four can be diagnosed without reading a single log line.
Queue depth is the obvious one, and the least informative on its own. Depth rising is normal during a burst and alarming only if it does not fall; what matters is the trend across a window long enough to cover your traffic shape. Alert on depth that has not decreased over several consecutive intervals rather than on any absolute value, because the threshold that is healthy at midday is a serious incident at three in the morning.
Oldest message age is the number that actually maps to user experience. A queue holding fifty thousand digests with an oldest age of forty seconds is healthy; one holding six messages with an oldest age of twenty minutes has a stuck consumer and a user waiting for a password reset. Publish it per lane, because a single global figure hides exactly the case the lanes exist to prevent.
Attempt distribution — how many messages are on their first attempt versus their third or fifth — reveals provider trouble before it becomes visible anywhere else. A healthy pipeline is overwhelmingly first-attempt. A rising tail means the provider is rejecting or throttling, and it rises well before the dead-letter queue starts filling.
Dead-letter arrival rate is the terminal signal, and it should normally be zero. Because every entry represents a message a real user never received, alert on any arrival rather than on a rate — a threshold implies an acceptable number of undelivered password resets, which there is not.
The pairing that catches the most incidents is depth against oldest age. Rising depth with a stable oldest age is a burst being absorbed correctly — the queue is doing its job. Stable depth with a rising oldest age is far more serious: messages are being consumed, but something in the ordering or the lane assignment is leaving a subset behind, which is the signature of a stuck partition or a lane with no workers assigned. Neither condition is visible from depth alone, which is why a dashboard showing only queue length routinely reports green through an incident.
Frequently Asked Questions
Should I use my ESP's built-in queue instead of my own?
Providers do queue and retry internally once they have accepted a message, and that covers the SMTP-level retries you should never implement yourself. What their queue cannot do is absorb the failure of the API call itself — if the provider is unreachable or throttling you, the message never reaches their queue. Your queue exists to cover the gap between your application and their front door; theirs covers everything after.
How long should the retry budget be for a password reset?
Shorter than the token's lifetime. If the reset link expires in fifteen minutes, retrying for an hour delivers a message that cannot be used and looks broken to the recipient. Set the budget to roughly two-thirds of the token lifetime and dead-letter after that, so the user requests a fresh reset rather than receiving a stale one.
Is a database table an acceptable queue?
For low volume, yes, and it has a real advantage: the enqueue can share a transaction with your application write, which removes an entire class of consistency bug. The limits show up under concurrency — polling workers contend on the same rows, and SELECT ... FOR UPDATE SKIP LOCKED becomes essential. Move to a dedicated broker when contention rather than throughput becomes the bottleneck.
Do I need exactly-once delivery?
No, and you cannot have it. Every practical queue offers at-least-once, so the achievable goal is at-least-once delivery with idempotent effects. The idempotency key on the send call is what converts a duplicate delivery into a duplicate that produces no second email, which is the property that actually matters to the recipient.
Should retries happen in the queue or inside the worker?
In the queue. An in-process retry loop holds a worker for the duration of the backoff, which wastes capacity and loses all state if the process restarts. Requeueing with a delay makes the retry durable, frees the worker immediately, and gives you queue depth as an observable signal of how much retrying is happening.
How do I stop a poison message from taking down the pool?
Bound both dimensions: cap attempts per message so it eventually dead-letters, and cap the worker's per-job execution time so a hang cannot hold a slot indefinitely. Without the second cap, a single message that never returns removes one worker permanently, and a handful of them silently reduce the pool to nothing.
Related
- ESP Selection and Integration — the provider abstraction the worker dispatches through
- Webhook Event Pipelines — where the outcome of each send comes back into your store
- Bounce and Complaint Handling — the suppression check that should precede every dispatch
- Email Deliverability and Reputation — why sustained, even send rates matter beyond throughput