TL;DR: Resending a failed email without idempotency guarantees risks duplicate sends. Safe retry logic combines idempotency keys tied to stable message identifiers, exponential backoff with jitter, and provider-side deduplication windows — preventing both message loss and duplicate delivery in AI agent workflows.

Email delivery fails. SMTP connections time out, provider rate limits trigger, DNS resolution hiccups mid-request, and upstream APIs return 503s under load. For a human clicking "send again" in a GUI, a duplicate email is an embarrassment. For an AI agent firing retries in a loop, it's a liability — users receive three password resets, five invoice notifications, or a dozen appointment confirmations.
This post covers the full retry stack: failure taxonomy, idempotency key design, backoff algorithms, deduplication windows, and the state machine an agent needs to retry safely without ever sending the same email twice.
Why email failure categories matter for retry strategy
Not all failures should be retried. Retrying the wrong failure class burns your sender reputation. The category determines whether you retry immediately, retry later, or drop the attempt entirely.
Email send failures fall into three buckets:
Transient failures (retry)
- Network timeouts — TCP connection to the SMTP relay or API endpoint dropped before a response arrived. The server may or may not have accepted the message.
- 5xx transient SMTP codes —
421 Service temporarily unavailable,451 Requested action aborted. The receiving MTA explicitly says "try again later". - Rate limit responses — HTTP 429 from an API provider, or SMTP
452 Too many recipients. Back off and retry. - Provider 503s — The sending API is under load. Retry with backoff.
Permanent failures (do not retry)
- 5xx permanent SMTP codes —
550 User unknown,551 User not local,553 Mailbox name not allowed. Retrying burns reputation and wastes sends. - Hard bounces — The remote MTA rejected with a permanent error. Remove from the sending list immediately.
- Invalid API parameters — HTTP 400 from your provider means you sent malformed data. Retrying the same payload achieves nothing.
- Authentication failures — HTTP 401/403 means a key rotation or permission issue. Fix it; don't retry.
Ambiguous failures (retry with idempotency)
- No response / connection reset — You sent the request but received nothing. The server may have accepted or dropped it. This is the dangerous case.
- HTTP 500 from provider — Could be pre- or post-acceptance. Unknown state.
- Timeout after request dispatched — Same problem.
Ambiguous failures are where idempotency keys become essential. Without them, you don't know if retrying causes a duplicate.
Idempotency key design
An idempotency key is a stable, deterministic identifier you attach to each send request. The provider uses it to deduplicate: if you send the same key twice within their deduplication window, they return the result of the first attempt and suppress the second send.
The key must be stable across retries (same key every time you retry a given logical message) and unique across distinct messages (different key for every logically different email). Most providers accept it as an HTTP header — Idempotency-Key or X-Idempotency-Key.
What to hash into your key
Don't use random UUIDs generated at send time — those change on each retry and defeat the purpose. Derive the key deterministically from the inputs that define the message's identity:
import hashlib
import json
def email_idempotency_key(
agent_run_id: str,
recipient: str,
template_id: str,
context_hash: str, # hash of dynamic content inputs
) -> str:
payload = json.dumps({
"run": agent_run_id,
"to": recipient.lower().strip(),
"template": template_id,
"ctx": context_hash,
}, sort_keys=True)
return hashlib.sha256(payload.encode()).hexdigest()[:40]
agent_run_id scopes the key to the specific agent execution. recipient and template_id scope it to the specific message. context_hash is a hash of any dynamic inputs (order ID, appointment time, etc.) — this ensures a genuinely different message gets a different key even if sent to the same recipient in the same run.
What NOT to include
- Timestamps generated at send time
- Random nonces
- Retry attempt number
- Wall clock values
Any non-deterministic field breaks the guarantee.
Exponential backoff with jitter
Retrying immediately after a transient failure just hammers the same overloaded system. Exponential backoff spaces retries geometrically. Jitter adds randomness to prevent retry storms when many agents fail simultaneously — the "thundering herd" problem.
import random
import time
def backoff_delay(attempt: int, base: float = 1.0, cap: float = 60.0) -> float:
"""
Full jitter backoff: delay = random(0, min(cap, base * 2^attempt))
attempt is 0-indexed (first retry is attempt=1)
"""
max_delay = min(cap, base * (2 ** attempt))
return random.uniform(0, max_delay)
async def send_with_retry(
send_fn,
payload: dict,
idempotency_key: str,
max_attempts: int = 5,
):
last_error = None
for attempt in range(max_attempts):
try:
result = await send_fn(payload, idempotency_key=idempotency_key)
return result
except PermanentEmailError:
raise # never retry permanent failures
except TransientEmailError as e:
last_error = e
if attempt < max_attempts - 1:
delay = backoff_delay(attempt)
await asyncio.sleep(delay)
raise MaxRetriesExceeded(f"Failed after {max_attempts} attempts") from last_error
Typical parameters for email sends: base of 1 second, cap of 60 seconds, 4–5 max attempts. That gives a retry spread of roughly 0–1s, 0–2s, 0–4s, 0–8s before the final attempt — a total window under 2 minutes in the worst case.
If a 2-minute block is unacceptable in your workflow, externalize the retry into a queue (see below).
The state machine: tracking sends across agent executions
In-process retry loops break when the agent process restarts, crashes, or gets rescheduled. Track send state persistently so any agent instance can pick up where another left off.
flowchart LR A[PENDING] --> B[IN_FLIGHT] B --> C[DELIVERED] B --> D[FAILED_TRANSIENT] B --> E[FAILED_PERMANENT] D --> F[RETRY_SCHEDULED] F --> B E --> G[DEAD_LETTER] C --> H[DONE]
Minimum schema for an email_sends table:
CREATE TABLE email_sends (
id UUID PRIMARY KEY,
idempotency_key TEXT UNIQUE NOT NULL,
agent_run_id TEXT NOT NULL,
recipient TEXT NOT NULL,
template_id TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
attempt_count INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ,
provider_message_id TEXT, -- populated on DELIVERED
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON email_sends (status, next_attempt_at)
WHERE status IN ('PENDING', 'RETRY_SCHEDULED');
A worker polls status IN ('PENDING', 'RETRY_SCHEDULED') AND next_attempt_at <= NOW(), marks the row IN_FLIGHT, attempts the send, then transitions to DELIVERED or one of the failure states. If the worker crashes mid-flight, a watchdog query (status = 'IN_FLIGHT' AND updated_at < NOW() - INTERVAL '5 minutes') resets those rows to RETRY_SCHEDULED.
The idempotency_key unique constraint is your last line of defense. Even if two workers race on the same row, provider-side deduplication (keyed on idempotency_key) suppresses the duplicate send.
Provider deduplication windows
Most providers implement idempotency deduplication within a finite window, commonly 24 hours, though this varies. Outside that window, the same key may trigger a new send.
A few practical implications. If your retry queue can hold a message for more than 24 hours, you cannot rely solely on provider-side deduplication — your database state machine must also check whether the message is already DELIVERED before issuing another send attempt. Always check your specific provider's documentation for their deduplication window, and treat it as an optimization, not a guarantee, beyond that point.
Never extend the deduplication window by rotating keys. If you need to send the same logical message again after the window expires, that's a conscious decision — generate a new key and record it as a separate send event.
Platforms built for agent email workloads, like Mails.ai's email API for agents, expose idempotency key support and per-send delivery status webhooks directly. That closes the ambiguous failure loop by giving you a confirmed delivered or bounced event rather than just a send-time response.
Handling the Message-ID and threading after a retry
When you retry a send, preserve the original Message-ID header. Email clients use Message-ID for deduplication in the recipient's inbox — Gmail and Outlook both suppress exact Message-ID duplicates. If your provider generates a new Message-ID on each API call, set it explicitly:
import uuid
def generate_message_id(domain: str) -> str:
return f"<{uuid.uuid4().hex}@{domain}>"
message_id = generate_message_id("mail.yourdomain.com")
Store the Message-ID in your email_sends record alongside the payload. On retry, send the same one. The provider passes it through as a header, and if the original did reach the recipient's MTA but your API call timed out before you got the 200 OK, the recipient's MTA will silently drop the duplicate.
For reply threading, the In-Reply-To and References headers reference the Message-ID of the original message in a thread. A retry is the same message, not a new one — never alter these headers.
Rate limiting and reputation protection
Beyond individual message retries, AI agents create aggregate sending patterns that can damage sender reputation if left unconstrained.
Per-recipient throttling: an agent retrying failed messages for a domain that's temporarily unavailable should not queue 500 retries to the same domain. Group retries by recipient domain and apply a per-domain rate limit. If @example.com is bouncing with 421s, back off all sends to that domain, not just the one that failed.
Bounce rate monitoring matters too. Google and Yahoo's 2024 bulk sender requirements set a hard limit of 0.3% spam rate and recommend keeping bounce rates under 2% to maintain deliverability. An agent that blindly retries hard bounces will hit these limits fast.
Define a retry budget per agent run, not just per message. If an agent run is failing 40% of its sends, something is systemically wrong. Alert and halt rather than continuing to retry.
For agents sending at scale, sender reputation management and dedicated IP infrastructure matter as much as retry logic — your retry behavior determines your IP reputation over time.
Frequently Asked Questions
Should I retry on HTTP 500 from my email API provider?
Yes, but with idempotency. HTTP 500 from a provider is ambiguous — they may have accepted the message before the internal error occurred. Always include an idempotency key so the provider can deduplicate if they did accept it. Without one, an HTTP 500 retry will likely produce a duplicate send.
What's the right number of retry attempts for email sends?
For synchronous, in-process retries: 3–5 attempts with full-jitter exponential backoff, capping at 60 seconds between attempts. For queue-based retries: up to 24 hours of total retry window is reasonable for transient failures, after which the message should move to a dead-letter state and trigger an alert. Permanent failures should never be retried at all.
Can I use a UUID4 as my idempotency key?
Only if you generate it once and store it before the first send attempt. Generate the UUID, persist it to your database with the message record, then use that same UUID on every retry. Generate a new UUID4 at retry time and you lose all deduplication benefits — each retry looks like a brand-new send to the provider.
How do I handle idempotency for emails that have genuinely changed between retries?
A change in content means it's a different logical message and should get a different idempotency key. Recompute the key by re-hashing the updated content inputs. Before issuing the new send, mark the original send record as superseded to avoid confusion in your audit log. Never mutate the content while reusing the same key — providers cache the original payload against the key and will send the cached version, not your update.
What happens if my idempotency key exceeds the provider's deduplication window?
The provider treats the request as a new send, regardless of the key. Your defense is the status field in your database: check that the message isn't already DELIVERED before issuing any send call, even if the deduplication window has elapsed. The database check is your authoritative guard; provider deduplication is a supplemental safety net.
How should an AI agent know whether a sent email was actually delivered?
Don't rely solely on the send-time API response. Subscribe to delivery status webhooks from your provider — they emit distinct events for delivered, bounced, deferred, and complained. On receiving a delivered event keyed to your provider_message_id, transition the database record to DONE. On bounced, transition to FAILED_PERMANENT and remove the recipient from future sends. This closes the feedback loop that ambiguous send failures leave open.