
error handling patterns for ai agent email: typescript vs python
Five error handling patterns every AI agent email pipeline needs, with TypeScript and Python code for retries, circuit breakers, fallbacks, and idempotency.
Your agent generates a reply, calls the LLM, and the API returns a 429. The retry fires, succeeds. But a second retry also fired from a race condition. Two identical emails land in the recipient's inbox. Or maybe the LLM returns malformed JSON, your agent parses it as an empty body, and sends a blank email to a paying customer.
These aren't hypothetical edge cases. They're what happens when you build an agent email pipeline without deliberate error handling. The patterns that prevent them are well-established in distributed systems, but they look different in TypeScript and Python. And most guides only cover one language for generic API calls, ignoring the failure modes specific to email: bounces, reputation damage, duplicate delivery, and the uncomfortable fact that email has no undo button.
Error handling patterns at a glance#
| Pattern | When to use | TypeScript approach | Python approach | Email-specific notes |
|---|---|---|---|---|
| Exponential backoff | Transient 429/503 errors | async/await + setTimeout | tenacity or asyncio.sleep | Read Retry-After headers from email APIs |
| Circuit breaker | Provider outage (repeated failures) | State machine with typed enum | pybreaker or manual class | Prevents burning sender reputation |
| Model fallback | Primary LLM unavailable | Typed provider array + for...of | Priority list + try/except | Validate output schema per model |
| Idempotency keys | Retry-induced duplicate sends | randomUUID() per operation | uuid.uuid4() per operation | Email has no undo: dedup is required |
| Dead-letter queue | Failures after N retries | Array/Redis + typed metadata | List/Redis + error dict | Human review before messages are lost |
These five patterns cover the core failure modes. The rest of this article walks through each one with real code in both languages, focused on the places where email pipelines diverge from generic API retry logic.
Transient vs permanent: the split that shapes everything#
Not all errors deserve a retry. Email pipelines deal with two distinct failure classes, and confusing them wastes compute or silently drops messages.
Transient errors are temporary: a 429 rate limit from your LLM provider, a 503 from an email API during a deployment window, a network timeout. These resolve on their own. Retry with backoff.
Permanent errors are final: a 550 SMTP bounce because the recipient address doesn't exist, a 401 because your API key was revoked, an LLM response that consistently fails schema validation. Retrying these accomplishes nothing. In the case of bounces, retrying is actively harmful to your sender reputation.
TypeScript's discriminated unions make this classification explicit at the type level:
type TransientError = { kind: 'transient'; code: number; retryAfter?: number };
type PermanentError = { kind: 'permanent'; code: number; reason: string };
type PipelineError = TransientError | PermanentError;
function shouldRetry(err: PipelineError): boolean {
return err.kind === 'transient';
}
In Python, exception subclasses serve the same purpose:
class PipelineError(Exception):
def __init__(self, code: int, message: str):
self.code = code
self.message = message
class TransientError(PipelineError):
retry_after: float | None = None
class PermanentError(PipelineError):
pass
The typed approach pays off downstream. Every catch block or except clause can make an immediate decision without parsing HTTP status codes or inspecting error strings at runtime.
Warning
Never retry a 550 SMTP bounce. Repeated delivery attempts to invalid addresses can get your sending domain blocklisted within hours.
Retry with exponential backoff#
This is the simplest pattern and the one most agents get wrong. A naive retry loop that fires immediately after a failure hammers an already-struggling API and triggers stricter rate limits.
async function withBackoff<T>(
fn: () => Promise<T>,
maxRetries = 4,
baseDelay = 500
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxRetries) throw err;
if (err instanceof PermanentError) throw err;
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200;
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error('Unreachable');
}
Python with tenacity:
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=0.5, max=30),
retry=retry_if_exception_type(TransientError)
)
async def send_with_backoff(email_payload: dict) -> dict:
return await email_client.send(email_payload)
The jitter (Math.random() * 200 in TypeScript, built into wait_exponential in tenacity) prevents thundering herd problems when multiple agents retry against the same API simultaneously.
One detail most guides skip: email APIs and LLM APIs express rate limits differently. OpenAI returns x-ratelimit-reset-tokens and x-ratelimit-reset-requests as separate headers. Most email APIs use a standard Retry-After header in seconds. Your backoff logic should read provider-specific headers when they're present rather than guessing at delays.
Circuit breaker: stop before you do damage#
When an email provider goes down for real (not a momentary blip), retrying every request wastes time and can damage your sending reputation. If your agent keeps sending into a black hole for ten minutes, some receiving servers will notice the pattern and start flagging your domain.
A circuit breaker tracks consecutive failures and "opens" after a threshold, blocking all requests for a cooldown period:
class CircuitBreaker {
private failures = 0;
private state: 'closed' | 'open' | 'half-open' = 'closed';
private nextAttempt = 0;
constructor(private threshold = 5, private cooldownMs = 30_000) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open' && Date.now() < this.nextAttempt) {
throw new Error('Circuit open: provider unavailable');
}
if (this.state === 'open') this.state = 'half-open';
try {
const result = await fn();
this.failures = 0;
this.state = 'closed';
return result;
} catch (err) {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'open';
this.nextAttempt = Date.now() + this.cooldownMs;
}
throw err;
}
}
}
In Python, pybreaker handles this out of the box, though a manual implementation follows the same state machine logic. The key insight for email specifically: set your failure threshold lower than you would for a generic API. Five consecutive send failures usually means an infrastructure problem, not bad luck.
Model fallback chains and graceful degradation#
When your primary LLM goes down, your agent still needs to respond to incoming email. Going silent is worse than sending an imperfect reply, because the sender has zero indication their message was received.
const providers = [
{ name: 'claude', client: claudeClient },
{ name: 'gpt4', client: openaiClient },
] as const;
async function generateReply(prompt: string): Promise<string> {
for (const provider of providers) {
try {
return await provider.client.generate(prompt);
} catch {
console.warn(`${provider.name} failed, trying next`);
}
}
return CANNED_RESPONSE;
}
That CANNED_RESPONSE at the bottom is graceful degradation in practice. Your agent acknowledges the email and promises a follow-up rather than dropping the message into a void. In Python, the same structure uses a for/try/except loop over a priority-ordered list of provider clients.
One catch worth noting: different LLMs produce different output structures. If your agent expects structured JSON for email composition, a fallback model might return slightly different field names or omit optional fields entirely. Validate the output schema after every generation call, regardless of which provider produced it. Zod in TypeScript, Pydantic in Python.
Idempotency: the pattern email can't skip#
Email has no recall function, no undo. Once a message is delivered, it's delivered. This makes idempotency non-negotiable for any agent that retries on failure.
The pattern is straightforward: generate a unique key per logical send operation and attach it to every attempt.
import { randomUUID } from 'crypto';
async function sendOnce(inbox: Inbox, draft: EmailDraft) {
const key = randomUUID();
await withBackoff(() => inbox.send({ ...draft, idempotencyKey: key }));
}
import uuid
async def send_once(inbox, draft: dict) -> None:
key = str(uuid.uuid4())
await send_with_backoff({**draft, "idempotency_key": key})
If your email API supports idempotency keys server-side, the server deduplicates for you. If it doesn't, you need client-side tracking: store sent keys in Redis or a local database and check before every send attempt.
Event delivery and production logging#
How your agent receives incoming email shapes your entire error handling surface. We covered this tradeoff in depth in webhooks vs polling: how your agent should receive emails, but here's the error-handling angle: webhooks introduce duplicate delivery risk (most systems retry on timeout), while polling risks processing the same email twice across poll cycles. Both require idempotent processing logic on the receiving side, not just the sending side.
For production logging, every error event in an agent email pipeline should include: timestamp, correlation ID (to trace back to the originating email), error classification (transient or permanent), provider name, retry attempt number, relevant email context (subject line, sender address), and the action your system took (retried, fell back, dead-lettered, escalated to a human).
interface ErrorLogEntry {
timestamp: string;
correlationId: string;
errorClass: 'transient' | 'permanent';
provider: string;
attempt: number;
context: { subject: string; sender: string };
action: 'retry' | 'fallback' | 'dead-letter' | 'escalate';
}
This structure answers "how many emails did we fail to process last Tuesday, and why?" without grepping through walls of unstructured text.
When to escalate to a human#
Not every error should be silently retried. Set clear thresholds: after 3 failed send attempts, move the message to a dead-letter queue and alert someone. For LLM generation failures, a threshold of 5 attempts is usually reasonable since LLM providers tend to recover faster than email deliverability issues.
If your agent consistently produces replies that fail schema validation, that's a signal the prompt needs adjustment, not more retries. Surface those failures to a human operator rather than burning through your API budget on a problem retries can't fix.
Start with backoff and idempotency. Those two patterns alone prevent the most common production failures: rate limit avalanches and duplicate emails. Layer in a circuit breaker once you're sending at volume, and build fallback chains when your agent handles time-sensitive replies where silence isn't acceptable.
If you're looking for an email API that handles idempotency and typed errors at the infrastructure layer, LobsterMail's SDK exposes both natively, so your agent code can focus on the LLM and business logic. and wire these patterns into a real pipeline.
Frequently asked questions
What is exponential backoff and why does every email AI agent need it?
Exponential backoff spaces out retry attempts with increasing delays (500ms, 1s, 2s, 4s, 8s) plus random jitter. Without it, agents hammer failing APIs with immediate retries, which triggers stricter rate limiting and makes the outage worse for everyone.
How does the circuit breaker pattern prevent a failing email API from crashing an agent workflow?
A circuit breaker counts consecutive failures and stops making requests after a threshold (typically 5). This avoids wasting compute during outages and, for email specifically, protects your sender reputation from repeated failed delivery attempts that receiving servers track.
What is the difference between transient and permanent errors in an agent email pipeline?
Transient errors (429 rate limits, 503 outages, network timeouts) are temporary and should be retried with backoff. Permanent errors (550 bounces, 401 auth failures, consistent schema validation failures) will never succeed no matter how many times you retry.
How do you implement a typed model fallback chain in TypeScript?
Create an array of provider objects with a consistent interface, iterate with for...of, and wrap each call in try/catch. If every provider throws, return a canned acknowledgment response. Validate the output schema after each call since different models produce different structures.
How do you handle email bounce errors differently from LLM rate-limit errors?
Bounces (5xx SMTP) are permanent: stop retrying, flag the recipient address, and log the failure. LLM rate limits (429) are transient: back off using the Retry-After header and retry. Confusing the two either burns your sender reputation or drops valid replies.
How do you enforce idempotency so an agent never sends duplicate emails?
Generate a unique UUID for each logical send operation and pass it as an idempotency key with every retry attempt. If your email API supports server-side idempotency, the server deduplicates automatically. Otherwise, track sent keys in Redis and check before each send.
Should an email AI agent use webhooks or polling, and how does the choice affect error handling?
Webhooks provide real-time delivery but require handling duplicate events (most webhook systems retry on timeout). Polling is simpler but risks processing the same email twice across cycles. Both need idempotent processing logic. See our webhooks vs polling guide for the full comparison.
How do you handle malformed JSON from an LLM when generating an email reply?
Wrap every LLM response in schema validation (Zod in TypeScript, Pydantic in Python). If validation fails, retry with the same prompt up to your attempt limit. After that, fall back to a canned response rather than sending a malformed or empty email.
What logging fields should every AI agent error event include?
At minimum: timestamp, correlation ID, error classification (transient or permanent), provider name, retry attempt number, email context (subject line, sender address), and the action taken (retried, fell back, dead-lettered, or escalated to a human).
How do you implement a dead-letter queue for emails an agent can't process?
After N failed retries, move the message payload and error metadata to a separate store (a Redis list, a database table, or a dedicated inbox). Set up an alert so a human operator can review and manually resolve the failures before messages are permanently lost.
When should an AI agent escalate an error to a human instead of retrying silently?
Escalate when failures suggest a systemic problem: repeated schema validation errors (the prompt needs fixing), consistent bounces from a single domain (possible blocklisting), or any permanent error pattern affecting multiple recipients. Silent fallbacks are fine for isolated transient issues only.
How do rate limit headers differ between email APIs and LLM APIs?
Most email APIs use a standard Retry-After header with a value in seconds. LLM providers like OpenAI use custom headers such as x-ratelimit-reset-tokens and x-ratelimit-remaining-requests. Your backoff logic should check for both formats and prefer explicit headers over hardcoded delays.
What is graceful degradation in AI agent email systems?
Graceful degradation means your agent still provides a useful response when components fail. If every LLM in your fallback chain is unavailable, the agent sends a canned acknowledgment ("Got your email, will follow up shortly") instead of going silent or crashing.
Does LobsterMail support idempotency keys for agent email sending?
Yes. LobsterMail's SDK accepts idempotency keys on send operations, and the server deduplicates automatically. Your retry logic can fire as many times as needed without risking duplicate delivery. .


