Ir al contenido principal
Chained AI agents: propagating timeouts correctly

Chained AI agents: propagating timeouts correctly

AI Integration
7 min readBy Daily Miranda Pardo

Agent C has been silent for 40 seconds. Agent B has spent 40 seconds waiting for Agent C. Agent A has spent 40 seconds waiting for Agent B. The user has been watching a spinner for 40 seconds.

No error logs. No alerts. The entire pipeline is frozen because nobody told the system how to cancel when one piece hangs.

This is the quietest architectural mistake in AI agent systems: each agent has its own timeout, but timeouts don't propagate. When the last agent in the chain hangs, the cost isn't one timeout — it's the sum of every timeout stacked in sequence.

The problem: timeouts that stack instead of cancel

Picture a typical pipeline: the user makes a request, an orchestrator agent (A) calls an analysis agent (B), which calls a drafting agent (C). Three agents, each with a 30-second timeout.

If C hangs — because the LLM API is overloaded, because the input is unusually long, because there's a prompt bug — what happens?

  • C waits until its own timeout: 30 seconds
  • B waits for C to respond. It has no idea C is hung. It waits another 30 seconds
  • A waits for B to respond. It knows nothing about C. It waits another 30 seconds

Result: the user waits 90 seconds before seeing an error. During those 90 seconds, all three agents are holding open connections, consuming memory, and burning API credits in active wait. Multiply by ten concurrent users in this state.

The problem isn't the individual timeouts. It's that nobody propagates the cancellation signal through the chain.

Why the standard pattern isn't enough

The natural instinct when building an agent chain looks like this:

async function runChain(input: string): Promise<string> {
  const step1 = await agentA(input)       // timeout: 30s
  const step2 = await agentB(step1)       // timeout: 30s
  const step3 = await agentC(step2)       // timeout: 30s
  return step3
}

Each agent has its own try/catch and its own setTimeout. Looks fine. The problem: if C throws after 30 seconds, B has already been blocked for 30 seconds waiting for C. When B receives C's error, it throws its own and A repeats the pattern.

What we actually want is the opposite: if any node in the chain fails or is cancelled, all other nodes cancel immediately. No stacking. No waiting.

Pattern 1: propagated AbortController

The right solution uses AbortController and passes the same AbortSignal to every node in the chain. When one signal aborts, every listener on that signal cancels instantly.

// Each agent accepts an AbortSignal and respects it
async function agentC(input: string, signal: AbortSignal): Promise<string> {
  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    signal,  // the signal reaches the fetch call
    body: JSON.stringify({ /* ... */ }),
    headers: { /* ... */ },
  })
  if (!response.ok) throw new Error(`LLM error: ${response.status}`)
  return await response.json()
}

// The chain shares the same signal
async function runChain(input: string, signal: AbortSignal): Promise<string> {
  const step1 = await agentA(input, signal)
  if (signal.aborted) throw new Error('Cancelled after step 1')

  const step2 = await agentB(step1, signal)
  if (signal.aborted) throw new Error('Cancelled after step 2')

  return await agentC(step2, signal)
}

// The orchestrator creates the AbortController with a global timeout
const controller = new AbortController()
const globalTimeout = setTimeout(() => controller.abort('global-timeout'), 25_000)

try {
  const result = await runChain(userInput, controller.signal)
  clearTimeout(globalTimeout)
  return result
} catch (err) {
  if (controller.signal.aborted) {
    // Clean cancellation: all agents already stopped
  }
  throw err
}

With this pattern, when the 25-second timeout fires, abort() reaches all active fetch() calls in the chain instantly. No stacking. No accumulation.

Pattern 2: decrementing timeout budget

The shared AbortController solves the global timeout problem. But there's a subtler second issue: each agent needs to know how much time remains in the total budget, not just whether it's been cancelled.

If Agent A decides "I have 25 seconds, I'll pass the signal to B," B doesn't know that 5 seconds already passed inside A. B assumes it has all the time in the world. By the time B passes the signal to C, the real remaining budget is much smaller.

The solution is a helper that creates chained signals with decreasing timeouts:

function withBudget(parentSignal: AbortSignal, budgetMs: number): AbortSignal {
  const controller = new AbortController()
  
  // Cancel if the parent cancels
  parentSignal.addEventListener('abort', () => {
    controller.abort(parentSignal.reason)
  }, { once: true })
  
  // Or if the local budget is exhausted
  const timer = setTimeout(() => {
    controller.abort(`budget-exceeded-${budgetMs}ms`)
  }, budgetMs)
  
  // Clean up timer if parent cancels first
  controller.signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
  
  return controller.signal
}

// Usage: 20s total budget, A gets 5s, B gets 8s, C gets whatever remains
async function runChain(input: string, parentSignal: AbortSignal) {
  const start = Date.now()

  const signalA = withBudget(parentSignal, 5_000)
  const step1 = await agentA(input, signalA)

  const elapsed = Date.now() - start
  const remaining = Math.max(0, 20_000 - elapsed)

  const signalB = withBudget(parentSignal, Math.min(8_000, remaining))
  const step2 = await agentB(step1, signalB)

  const remainingFinal = Math.max(0, 20_000 - (Date.now() - start))
  const signalC = withBudget(parentSignal, remainingFinal)
  return await agentC(step2, signalC)
}

This pattern guarantees that the sum of all partial timeouts never exceeds the total budget. If A takes longer than expected, B and C get less time — the system fails fast instead of accumulating latency.

Detecting client disconnect in Next.js

There's a third scenario most people miss: the user closes the tab or loses connectivity while the chain is running. The HTTP client has already disconnected, but the agents keep running and burning tokens.

In Next.js App Router, the API route's request exposes its own signal:

// app/api/agent/route.ts
export async function POST(request: Request) {
  const body = await request.json()
  
  // This signal aborts automatically when the client disconnects
  const { signal } = request
  
  try {
    const result = await runChain(body.input, signal)
    return Response.json({ result })
  } catch (err) {
    if (signal.aborted) {
      // Client is gone. Nothing to return.
      return new Response(null, { status: 499 })
    }
    return Response.json({ error: 'Pipeline failed' }, { status: 500 })
  }
}

By passing request.signal directly to the chain, any client disconnect cancels the entire pipeline instantly. No more tokens burned. No open connections accumulating.

The production difference

The gap between an agent chain without and with timeout propagation shows up clearly in metrics:

  • Error latency: drops from "sum of all timeouts" to "first timeout that fires"
  • Token spend on failures: approaches zero for requests the client is no longer waiting for
  • Open connections under load spikes: drop sharply because agents release resources on cancel

This isn't premature optimisation. In any real AI integration in production with multi-agent pipelines, this pattern is the difference between a system that scales and one that seizes under real load.

Conclusion

Per-agent timeouts don't protect the full pipeline. They just propagate errors accumualtively, multiplying latency and cost with every failure.

The correct approach has three layers: a shared AbortController for immediate cancellation, a decrementing timeout budget to cap total spend, and HTTP client signal propagation to free resources when the user is already gone.

If you're building a multi-agent system and want to get the architecture right from the start, tell me about your project on WhatsApp. We'll review the architecture and I'll tell you exactly where the risk is.

Share article

Repetitive processes in your business?

Download the free AI Automation Map — the 5 most time-consuming processes and how to fix them.

No spam. Just the PDF. Unsubscribe anytime.

Written by Daily Miranda Pardo

I help businesses automate processes, build AI agents and connect intelligent systems.