ALL WRITING
Article · The Email Orchestrator · Part 2

One Dead Inbox Shouldn't Kill the Whole Query

An MCP connection to a mailbox is a child process, and child processes die. Here's the self-healing reconnection — isolation, a drop-detector, and a cooldown — that turned a flaky multi-account agent into one you can leave running for a week.

Distributed SystemsTypeScriptMCPReconnectionallSettled

For about a day, one of the three inboxes was invisible. The agent would answer "what's urgent across all my mail?" cheerfully and completely — from two accounts. The third, a Yahoo mailbox reached over IMAP, had quietly dropped out.

No error surfaced to the user. The logs, if you went looking, showed the same line on repeat: Unexpected close. The connection to that mailbox had died hours ago, and every query since had been talking to a corpse and calling it a failed account.

This is the unglamorous half of building an agent that consolidates other services: the consolidation is a weekend; keeping it alive is the product. A connection to a mailbox isn't an HTTP request you fire and forget — in this design it's a long-lived MCP session to a child process, and child processes exit. IMAP servers hang up idle connections. Laptops sleep. A provider server crashes and restarts. This article is the machinery that treats all of that as normal weather instead of an outage. (The hub design it builds on is Part 1.)

01Why "connect once and reuse" is a trap

The natural first design is the one everybody writes: connect to each provider at startup, stash the client, reuse it. It's efficient — no per-call handshake — and it works flawlessly in every demo, because demos run for ninety seconds on a good network. It has two independent failure modes, and this project hit both.

A boolean set once is a lie. The first version marked an adapter connected = true after the handshake and never revisited it. But "connected" isn't a fact you learn once; it's a state that changes without asking you. When the Yahoo child hung up, nothing flipped that boolean back. The adapter kept reporting itself healthy, kept routing calls into a dead pipe, and kept throwing Unexpected close — forever, because nothing in the code path could ever decide to reconnect.

One failure taking down the whole answer. The other naive instinct is to fan out with Promise.all. But Promise.all rejects the instant any promise rejects. So the moment one account has an expired token, the entire cross-account query throws, and a user with three healthy inboxes and one flaky one gets nothing from any of them. The blast radius of one bad account is all accounts.

Both bugs share a root: the code assumed a connection is a thing you have, when it's really a thing you maintain.

02Isolate, self-heal, and give up fast

Three mechanisms, each targeting one way the naive version failed.

connected drop detected cooling down 60s reconnecting onclose/onerror next call success → connected connect fails cooldown ends → retry
A connection is a state you maintain, not a boolean you set. A drop flips it to reconnect-on-next-use; a failed connect cools down for 60s so a dead account never stalls the fan-out.

1. Isolation, via allSettled. The fan-out uses Promise.allSettled, which waits for every account to either succeed or fail and hands back the outcomes side by side. Fulfilled accounts contribute their emails; rejected ones get logged and skipped. Three good inboxes and one dead one returns three inboxes' worth of mail, not an exception.

2. Self-healing, via drop-detection + reconnect-on-use. Instead of trusting a boolean, the adapter wires the MCP client's onclose and onerror events back to its own state: a drop flips connected = false, so the next call sees a disconnected adapter and transparently reconnects before running. And if a connection dies mid-call, the operation catches the specific "connection dropped" error, reconnects once, and retries. Reads are idempotent, so a single retry is safe.

3. Giving up fast, via a cooldown. Self-healing has a failure mode of its own: if an account is genuinely dead — wrong password, server down — retrying it on every query would stall the whole fan-out, because even a failed connect costs a timeout. So a failed connect starts a 60-second cooldown: during it, that account fails instantly. A dead account degrades to "temporarily absent," never "makes everything slow."

Two smaller pieces hold it together. A connection generation counter invalidates stale event handlers — when you tear down socket #3 and open socket #4, the old socket's late onclose must not flip the new one to disconnected. And an in-flight connect promise dedupes concurrent callers, so four tools hitting a reconnecting account share one handshake instead of racing to open four.

03The implementation

ensureConnected is the heart of it. Read it as three guards before the actual connect: already connected (do nothing), already connecting (join that attempt), recently failed (fail fast).

providers/provider-adapter.ts
async ensureConnected(): Promise<void> {
  if (this.isConnected()) return;
  if (this.connectPromise) return this.connectPromise;   // dedupe concurrent callers

  const err = this.lastConnectError;                     // fail fast during the cooldown window
  if (err && Date.now() - err.at < RECONNECT_COOLDOWN_MS) {
    throw new ProviderConnectionError(this.provider, `Not reconnecting yet (cooling down)`);
  }

  this.connectPromise = (async () => {
    try { await this.establishWithRetry(); await this.afterConnect(); this.lastConnectError = null; }
    catch (e) { this.lastConnectError = { at: Date.now(), message: String(e) }; throw e; }
  })().finally(() => { this.connectPromise = null; });
  return this.connectPromise;
}

The drop-detector is what makes "the next call reconnects" true. On every successful connect, the handlers below get wired — and each captures the generation it was born under, so a handler from an old socket bails the moment it notices it's been superseded.

const generation = ++this.connectionGeneration;
const onDrop = (reason?: unknown): void => {
  if (generation !== this.connectionGeneration) return;  // superseded by a newer connection — ignore
  this.connected = false;
  this.client = null;
  this.onConnectionLost();
};
client.onclose = () => onDrop();
client.onerror = (e: Error) => onDrop(e);

Mid-flight recovery lives in withConnection, which wraps every downstream call. The subtle part is isConnectionDrop: a reconnect-and-retry is only safe for errors that mean the pipe died, not for a normal tool-level failure ("no such message"). Retrying the latter would just repeat a legitimate error.

private async withConnection<T>(fn: () => Promise<T>): Promise<T> {
  await this.ensureConnected();
  try { return await fn(); }
  catch (error) {
    if (!isConnectionDrop(error)) throw error;           // a real tool error — don't retry
    await this.teardown(); this.onConnectionLost(); await this.ensureConnected();
    return await fn();                                    // reads are idempotent → one retry is safe
  }
}

isConnectionDrop is deliberately a broad regex, because the same "the child died" reality surfaces under a dozen different messages depending on where in the stack it's noticed — the MCP SDK's Connection closed, or the raw socket's ECONNRESET / EPIPE / socket hang up / write after end.

04The dials, and why each is set where it is

DialValueWhy this
CONNECT_TIMEOUT_MS60,000Gmail's server refreshes OAuth on startup — ~30s observed. A tighter cap kills a healthy account mid-handshake
RECONNECT_COOLDOWN_MS60,000After a failed connect, one dead account must not re-stall every fan-out. Fail fast for a minute, then allow another try
CONNECT_RETRIES3Transient blips retry (0.5s, then 1s); auth failures fail fast — retrying a wrong password never helps
Urgent-notify dedup setbounded 5,000Remember which urgent emails we've alerted on, without unbounded memory growth
Test suite49 tests, all greenTwo exist only to reproduce the two production drops below

The auth-failure carve-out matters more than it looks. Transient errors and permanent ones want opposite treatment — retry the timeout, fail fast on the 401 — so the connect loop throws immediately on anything matching unauthorized/forbidden/401/403/invalid token. Retrying a bad credential just burns the cooldown budget on an outcome that will never change.

05What broke

Both of the bugs this machinery exists for are pinned in a regression test, because a resilience feature you can't reproduce on demand is one you'll break next month.

1. Yahoo "Unexpected close" — and the twist that made it hard. The IMAP child's connection drops mid-request; the adapter must reconnect. Fine. But the IMAP MCP server holds its mail account in per-connection state — each fresh child process starts with an empty account store. So reconnecting isn't enough: the adapter has to re-provision the account into the new child before any read will work, or it reconnects successfully and then returns zero emails from a mailbox that's full. The fix is a per-provider afterConnect hook, and the test asserts exactly this:

adapter.dropNextGetLatest();                 // next read kills the child mid-request
const recovered = await adapter.listEmails();
expect(recovered).toHaveLength(1);           // read still succeeds
expect(adapter.connectCount).toBe(2);        // reconnected to a fresh child
expect(adapter.addCount).toBe(2);            // AND re-provisioned the account into it

2. The connection that stayed "connected" forever. The boolean that never flipped back. The test drops the child between calls and asserts the adapter both notices and transparently recovers on the following call.

My favourite bug, because it's so mundaneGmail reported zero unread, always. Its MCP server returns search results as plain text with no read/unread flag, and the normalizer's default for "no flag present" was read. So account_status proudly reported zero unread on an inbox with forty. No crash, no log — a number that was confidently wrong. The failures that hurt in a multi-service agent are the silent ones, and the only defence is a test that asserts the value, not just the absence of an exception.

06What I'd do differently

Surface degraded accounts to the user, not just the log. Today a cooling-down account is invisible in the answer — the digest is silently built from two inboxes instead of three, and only stderr knows. inbox_summary should carry a line like "Yahoo unavailable (reconnecting)." Isolation without disclosure is just a nicer way to hide a problem — the exact trap the Gmail-unread bug fell into.

Add a real health check. Connection liveness is currently proven only by attempting a real operation. A cheap periodic listTools ping would detect a dead child before a user query does, so recovery happens in the background instead of on the critical path.

The through-line for the whole series is here: the moment your agent orchestrates other services, their failure modes become yours. Isolation, drop-detection, and a cooldown aren't gold-plating — they're the difference between a thing that demos and a thing you can leave running while you get on with your day.

The transferable lesson

  • A connection is a state you maintain, not a boolean you set once — wire drop events back to that state.
  • Isolate accounts with allSettled; reconnect on next use; cool down a failed connect so a dead account degrades to "absent," never "slow."
  • Only retry errors that mean the pipe died; fail fast on auth errors and real tool errors.
  • In a multi-service agent the dangerous failures are silent — assert the value, not just the absence of an exception.
Part 1 · The hub Part 3 · The token bug
Nirmit K. Tripathii
Nirmit K. Tripathii — AI/ML Engineer & Football Data Scientist
Three years building NLP, agentic AI and multimodal systems in production. I write up real projects — the architecture, the numbers, and the parts that failed. Available for part-time, consulting and research collaboration (remote).