DEV Community

Cover image for AI Agent Tool Timeouts in Node: What the Model Should See When a Tool Hangs
Gabriel Anhaia
Gabriel Anhaia

Posted on

AI Agent Tool Timeouts in Node: What the Model Should See When a Tool Hangs


An agent turn is only as fast as its slowest tool. One upstream service takes
forty seconds to fail, and the whole run sits there — the user watching a
spinner, your request holding a connection, the turn budget ticking.

Node makes this easy to get wrong in a specific way: await has no deadline,
and fetch without a signal will wait as long as the socket stays open.

A deadline is not a timeout

Most implementations put a timeout on each tool call. That is necessary and
insufficient, because five tools with a ten-second timeout each is a
fifty-second turn.

What you want is a run deadline that individual calls inherit:

export type Deadline = { at: number };

export const remaining = (d: Deadline) => Math.max(0, d.at - Date.now());

export function budgetFor(d: Deadline, toolMax: number) {
  return Math.min(toolMax, remaining(d));
}
Enter fullscreen mode Exit fullscreen mode

Now a tool that would normally get ten seconds gets three if only three
remain, and the run stays inside its overall promise to the caller.

const deadline: Deadline = { at: Date.now() + 60_000 };
Enter fullscreen mode Exit fullscreen mode

Per-tool budgets, because tools differ

const TIMEOUTS: Record<string, number> = {
  search_docs: 5_000,
  get_order: 3_000,
  generate_report: 30_000,
  send_email: 8_000,
};
const DEFAULT_TIMEOUT = 10_000;
Enter fullscreen mode Exit fullscreen mode

A uniform timeout is either too short for the report generator or too long for
a primary-key lookup. Neither is acceptable, and the table costs nothing.

Cancellation that actually cancels

The part people get wrong: racing a promise against a timer stops your
waiting but not the work.

// wrong — the fetch keeps going, the socket stays open
const out = await Promise.race([
  runTool(block),
  new Promise((_, rej) => setTimeout(() => rej(new Timeout()), ms)),
]);
Enter fullscreen mode Exit fullscreen mode

The signal has to reach the I/O:

export async function executeWithDeadline(
  block: ToolUseBlock,
  ctx: Ctx,
  deadline: Deadline,
): Promise<ToolResultBlockParam> {
  const ms = budgetFor(deadline, TIMEOUTS[block.name] ?? DEFAULT_TIMEOUT);
  if (ms <= 0) {
    return errorResult(block.id,
      "Skipped: the overall time budget for this task is exhausted.");
  }

  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), ms);

  try {
    const out = await runTool(block, { ...ctx, signal: ac.signal });
    return okResult(block.id, out);
  } catch (err) {
    if (ac.signal.aborted) {
      metrics.increment(`tool.timeout.${block.name}`);
      return errorResult(block.id, timeoutMessage(block.name, ms));
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}
Enter fullscreen mode Exit fullscreen mode

Three details that matter in Node.

clearTimeout in finally — an uncleared timer keeps the event loop alive,
and a process that will not exit after its work finishes is genuinely
unpleasant to diagnose.

Checking ac.signal.aborted rather than the error type — an aborted fetch
throws an AbortError, but a downstream library may wrap it into something
else. The signal is the reliable witness.

Passing signal down to every fetch and database call inside the tool. A
signal that stops at the tool boundary has cancelled nothing.

async run({ query }, ctx) {
  const r = await fetch(url, { signal: ctx.signal });
  return r.json();
}
Enter fullscreen mode Exit fullscreen mode

A timeout that aborts the socket versus one that only stops waiting while<br>
work continues.

What the model should be told

This is the part that decides whether the run recovers. A timeout is not a
generic failure — it carries information the model can act on.

function timeoutMessage(tool: string, ms: number): string {
  return [
    `${tool} did not respond within ${Math.round(ms / 1000)}s.`,
    "This is a timeout, not an empty result — the data may exist.",
    "Do not retry with the same arguments.",
    "Either narrow the request, use a different tool, or continue without it",
    "and tell the user which part is missing.",
  ].join(" ");
}
Enter fullscreen mode Exit fullscreen mode

Every line is doing work. Distinguishing timeout from empty result stops the
model concluding the record does not exist. Forbidding an identical retry
prevents the loop this otherwise causes. And offering three explicit options
is what turns a dead end into a next step.

Compare it to what most implementations send:

return errorResult(block.id, "Error: ETIMEDOUT");
Enter fullscreen mode Exit fullscreen mode

The model has no idea whether to retry, give up, or apologise, so it usually
retries.

Parallel calls share the deadline

When a turn emits several tool calls, they run concurrently and all inherit
the same run deadline:

const outs = await Promise.all(
  blocks.map((b) => executeWithDeadline(b, ctx, deadline)),
);
Enter fullscreen mode Exit fullscreen mode

Promise.all is safe here precisely because executeWithDeadline never
rejects — every path returns a result block. That is the property that makes
partial failure work: three tools succeed, one times out, and the model
receives all four outcomes.

Degrade instead of failing

For read-only tools, a timeout does not have to mean nothing.

async run({ query }, ctx) {
  try {
    return await liveSearch(query, ctx.signal);
  } catch (err) {
    if (!ctx.signal.aborted) throw err;
    const cached = await cache.get(query);
    if (cached) {
      return { ...cached, stale: true, note: "Cached result; live search timed out." };
    }
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

The stale flag and the note are both for the model. A stale answer presented
as current is worse than a timeout; a stale answer labelled stale is usually
better than nothing, and the model will caveat it appropriately.

Watch the p99, not the mean

metrics.histogram("tool.duration_ms", ms, { tool: block.name });
metrics.increment(`tool.outcome.${block.name}.${outcome}`);
Enter fullscreen mode Exit fullscreen mode

Timeout budgets should be set from the p99 of a healthy day, not from a round
number someone liked. A tool whose p99 is 4.2 seconds does not want a
three-second timeout, and you will only find that out from the histogram.

The ratio worth alerting on is timeouts as a share of calls per tool. A tool
that times out on one call in twenty is degrading your agent invisibly — the
runs still complete, just with a quarter of the information missing.

Timeout budgets derived from measured p99 rather than round<br>
numbers.

The checklist

Every tool gets a budget derived from a run-level deadline. Every abort signal
reaches the actual I/O. Every timer is cleared in finally. Every timeout
returns a result block, never a throw. Every timeout message says it was a
timeout, forbids the identical retry, and offers a next step.

Get those five right and a hanging upstream service costs you one degraded
answer instead of a hung request and a burnt budget.


If this was useful

AI That Acts covers the execution
layer of an agent — deadlines, cancellation, partial failure, and error
results written so the model can recover from them.

AI That Acts — Tool Calling in TypeScript

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)