An email circuit breaker pattern protects your pipeline from cascading failures when an email provider is slow, rate-limited, or down. Like the electrical original, it trips open when error rates exceed a threshold, stops outbound calls entirely, then tests recovery with a single probe before resetting. This matters more for agents than for typical apps: unlike a web request a human retries manually, an agent will loop, schedule more tasks, and compound the damage before anyone notices.
Why agents need circuit breakers more than regular apps
A standard web app fails a user request and stops. An AI agent is stateful and scheduled — it may queue work, spawn sub-agents, or retry autonomously. When the email API starts returning 429 Too Many Requests or 503 Service Unavailable, a naive retry loop does three damaging things at once: it exhausts your hourly send budget, generates duplicate sends if idempotency isn't perfect, and locks up threads waiting on I/O when the agent should be processing other work.
According to Google's SRE Book, cascading failures are among the top causes of large-scale service outages, and the core mechanism is exactly this: one slow dependency causes queues to back up, which causes more retries, which worsens the slow dependency. Email pipelines are especially prone to this because latency is high (SMTP handshakes, DNS lookups, provider queuing) and volume can spike suddenly when a trigger fires across many users at once.
A circuit breaker gives the pipeline a place to fail fast instead of fail slow.
The three-state machine
The circuit breaker has three states.
Closed — normal operation. Calls pass through. Failures increment a counter. If the failure count exceeds failureThreshold within a rolling window, the breaker trips to Open.
Open — fast-fail mode. No calls are attempted. Every request raises CircuitOpenError immediately, which the agent handles as "email unavailable" and routes accordingly (queue the task, return a graceful degradation, alert ops). After resetTimeout milliseconds, the breaker moves to Half-Open.
Half-Open — recovery probe. The next single call is allowed through. If it succeeds, the breaker resets to Closed and the counter clears. If it fails, the breaker returns to Open and starts the resetTimeout again.
flowchart LR A[Closed] -->|failures exceed threshold| B[Open] B -->|resetTimeout expires| C[Half-Open] C -->|probe succeeds| A C -->|probe fails| B
Implementing a circuit breaker in TypeScript
Here is a minimal, production-shaped implementation. It wraps any async function — send, inbound poll, webhook dispatch — not just email specifically.
type BreakerState = "CLOSED" | "OPEN" | "HALF_OPEN";
interface BreakerOptions {
failureThreshold: number; // trips open after this many failures
successThreshold: number; // consecutive successes needed to close from half-open
resetTimeout: number; // ms to wait in OPEN before trying half-open
windowMs: number; // rolling window for failure counting
}
class CircuitBreaker {
private state: BreakerState = "CLOSED";
private failures: number[] = []; // timestamps of recent failures
private successes = 0;
private openedAt: number | null = null;
private opts: BreakerOptions;
constructor(opts: BreakerOptions) {
this.opts = opts;
}
async call<T>(fn: () => Promise<T>): Promise<T> {
this.evictOldFailures();
if (this.state === "OPEN") {
const elapsed = Date.now() - (this.openedAt ?? 0);
if (elapsed < this.opts.resetTimeout) {
throw new Error("CircuitOpenError: email pipeline unavailable");
}
this.state = "HALF_OPEN";
this.successes = 0;
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private onSuccess() {
this.failures = [];
if (this.state === "HALF_OPEN") {
this.successes++;
if (this.successes >= this.opts.successThreshold) {
this.state = "CLOSED";
}
}
}
private onFailure() {
this.failures.push(Date.now());
if (
this.state === "HALF_OPEN" ||
this.failures.length >= this.opts.failureThreshold
) {
this.state = "OPEN";
this.openedAt = Date.now();
}
}
private evictOldFailures() {
const cutoff = Date.now() - this.opts.windowMs;
this.failures = this.failures.filter(t => t > cutoff);
}
getState(): BreakerState {
return this.state;
}
}
Now wrap your send call:
import { mails } from "@mailsai/sdk";
const emailBreaker = new CircuitBreaker({
failureThreshold: 5,
successThreshold: 2,
resetTimeout: 30_000, // 30 seconds
windowMs: 60_000, // 1-minute rolling window
});
const hello = mails.agent("hello");
async function safeSend(to: string, subject: string, body_text: string) {
return emailBreaker.call(() =>
hello.send({ to, subject, body_text })
);
}
Five sends fail within sixty seconds, the breaker opens. The agent's task scheduler catches CircuitOpenError, parks the pending tasks in a local queue, and checks back after thirty seconds.
Classifying failures correctly
Not every error should trip the circuit. Count only failures that indicate provider-side degradation — network errors, 5xx responses, and 429 rate limits. Do not count:
422validation errors (bad recipient, cold-email rejection) — permanent logic errors, not transient outages401/403auth failures — misconfiguration, not a provider outage- Bounce events — delivery-layer signals, not API-layer failures
function isTransient(err: unknown): boolean {
if (err instanceof Error) {
// Match HTTP status codes from the error message or a structured error type
if (/5\d\d/.test(err.message)) return true;
if (/429/.test(err.message)) return true;
if (/ECONNREFUSED|ETIMEDOUT|ENOTFOUND/.test(err.message)) return true;
}
return false;
}
Then in onFailure, only increment when isTransient(err) is true.
Integrating with inbound pipelines
Circuit breakers apply equally to the receive side. If your agent processes inbound email via webhooks and the downstream task handler is overwhelmed — an LLM call taking 8 seconds, a database write stalling — you can trip a breaker on the webhook processor side and return 503 to the email platform. This tells the platform to retry delivery later, buying the agent time to drain its backlog.
The pattern also protects classification and routing calls. If an external classifier is down, the breaker keeps your email classification pipeline from blocking indefinitely on each new message.
Persisting state across restarts
An in-memory circuit breaker resets every time the process restarts. For agents deployed as serverless functions or in containers that scale to zero, this is a real problem — the breaker never accumulates failure history.
Store breaker state in Redis or any fast KV store:
// Pseudo-code — adapt to your Redis client
async function loadState(key: string): Promise<BreakerState> {
const raw = await redis.get(`breaker:${key}:state`);
return (raw as BreakerState) ?? "CLOSED";
}
async function persistState(key: string, state: BreakerState, openedAt: number | null) {
await redis.set(`breaker:${key}:state`, state);
if (openedAt) await redis.set(`breaker:${key}:openedAt`, String(openedAt));
}
For failure counts, use a Redis sorted set keyed by timestamp — ZADD on failure, ZREMRANGEBYSCORE to evict old entries, ZCARD to count. This gives you an exact rolling-window count across all instances of a horizontally scaled agent.
Thresholds for email-specific workloads
Email APIs fail differently than a REST CRUD API. Starting points:
| Parameter | Transactional burst | Steady async agent |
|---|---|---|
failureThreshold |
3 in 30s | 5 in 60s |
resetTimeout |
15s | 30–60s |
successThreshold |
1 | 2 |
windowMs |
30s | 60s |
For a burst sender dispatching hundreds of emails per minute, a tight window (30 seconds, 3 failures) trips fast and prevents a flood of retries. For a slower async agent processing inbound triggers, a wider window avoids false positives from the occasional slow response.
The sender reputation implications matter here too. If the breaker fires because the provider is rate-limiting you for high bounce rates, opening the circuit is the right call — but the root fix is cleaning the recipient list, not just retrying.
Observability
The breaker is only useful if you can see it. Emit structured events on every state transition:
console.log(JSON.stringify({
event: "circuit_breaker_transition",
from: previousState,
to: this.state,
failures: this.failures.length,
ts: new Date().toISOString(),
}));
Alerts worth configuring:
- Breaker transitions to
OPEN— page on-call - Breaker stays
OPENfor more than 5 minutes — escalate - Half-open probe fails three times in a row — investigate provider status
Mails.ai's API returns structured error bodies with code and message fields, which makes it straightforward to extract status codes for the isTransient classifier without parsing error strings.
Combining with retry logic
Circuit breakers and retries are complementary, not alternatives. Retries handle transient single-call failures — a packet dropped, a brief spike. The circuit breaker handles sustained degradation where retrying would make things worse.
The correct layering:
- Call fails
- Retry with exponential backoff (max 3 attempts, cap at 8s)
- If retries are exhausted, the circuit breaker records the failure
- If failure count exceeds threshold, breaker opens
- Pending tasks queue locally or in Redis
- Breaker probes after
resetTimeout, resets on success
Never put the circuit breaker inside the retry loop. The breaker wraps the outer call; retries happen inside it.
Frequently Asked Questions
How is a circuit breaker different from a retry with backoff?
Retry with backoff handles isolated failures — you try again after a delay, hoping the transient issue resolved. A circuit breaker handles sustained failures — after enough consecutive errors, it stops trying entirely and fails fast until the system recovers. Use both: retries inside the call, breaker wrapping the whole operation.
Should I have one breaker per agent or one per email provider?
One per provider (or per logical dependency) is the right level of granularity. Multiple agents sharing the same email API should share the same breaker state — stored in Redis — so one agent's failure history informs the rest. Per-agent breakers miss the signal that the provider itself is degraded.
What should an agent do when the circuit is open?
Depends on urgency. For non-time-sensitive tasks: park the payload in a queue and reschedule after resetTimeout. For time-sensitive sends: return a degraded response to the caller (e.g., "email queued, will send when available"). Never silently drop the message — always persist the intent somewhere durable.
How do I avoid false positives from slow LLM calls triggering the email breaker?
Measure timeouts precisely. Set a per-call timeout on your email API call (e.g., 10 seconds), separate from any LLM timeout. A slow LLM should timeout and fail its own breaker, not cascade into the email breaker. Keep dependency graphs shallow and isolated.
Does a circuit breaker help with email rate limits specifically?
Yes. When a provider returns 429 Too Many Requests, that's exactly the signal the breaker should count. Tripping open immediately stops you from sending more requests that will also be rejected, and gives the rate-limit window time to reset before the half-open probe fires. Pair this with exponential backoff on retries for a defense-in-depth approach.
Can I use a circuit breaker for inbound webhook processing?
Absolutely. Return 503 from your webhook handler when the circuit is open — most email platforms, including those that deliver to your inbound pipeline, will retry delivery for several hours. This is a clean way to apply backpressure without losing messages.
Related guides: Detecting Auto-Replies to Prevent Agent Email Loops, AI for Email: Integrating Agents Into Transactional Flows, AI for Email Automation: Triggers, Parsing & Actions and Inbound Email Webhooks for AI Agents: Parse & Act.
Explore the product: Inbound email API, Sender reputation, Email classification, All features and Pricing.
