The least interesting way an agent falls over is also one of the most common: a single node fires an HTTP request at a tool or model, the connection quietly dies, and the whole run just… waits. No error, no progress, no timeout — until something upstream gives up minutes later. LangGraph 1.2 (May 12, 2026) fixes this at the node level, and the API is small. The only thing you have to get right is which timeout you reach for.

The 30-second answer#

Pass a timeout to add_node. Use run_timeout for nodes that should either return fast or be considered stuck, and idle_timeout for nodes that legitimately run long but should never stall. Pair either with a RetryPolicy so a transient hang retries instead of killing the run.

from langgraph.graph import StateGraph
from langgraph.types import TimeoutPolicy, RetryPolicy

builder = StateGraph(State)

builder.add_node(
    "call_tool",
    call_tool,                                  # async def
    timeout=TimeoutPolicy(run_timeout=30),      # hard 30s wall-clock cap
    retry_policy=RetryPolicy(max_attempts=3),   # a stuck attempt retries
)

That's the whole pattern for a typical tool or single-completion node. The rest of this piece is about the one case where that snippet is wrong.

Two timeouts, and they are not interchangeable#

TimeoutPolicy exposes two limits, and confusing them is how people accidentally kill healthy nodes.

run_timeout is a hard wall-clock cap on one attempt. It starts when the node starts and counts straight down. When it hits zero, the attempt dies — no matter what the node was doing. This is exactly what you want for a tool call or a single LLM completion: those either come back in a couple of seconds or they're wedged on a dead socket, and you never want to wait more than N seconds to find out.

idle_timeout is a progress-resetting cap. It only fires when the node has made no observable progress for N seconds. Every time the node signals progress, the clock resets to full. This is the one for nodes that legitimately run long — a streaming generation, a batch loop over 500 records — where the danger isn't "it's taking a while," it's "it silently stopped."

You can pass a plain number or a timedelta as shorthand for a run_timeout, or spell out both:

# shorthand: a hard 30s cap
builder.add_node("quick", quick, timeout=30)

# idle-only: fine to run long, not fine to stall
builder.add_node("stream", stream, timeout=TimeoutPolicy(idle_timeout=30))

# both: never exceed 120s total, never stall for 30s
builder.add_node("hybrid", hybrid,
                 timeout=TimeoutPolicy(run_timeout=120, idle_timeout=30))

The trap: a wall-clock cap on a streaming node#

Here's the mistake that looks correct and isn't. You have a node that streams a long answer, and you slap a run_timeout=30 on it to "be safe." A healthy 45-second generation now dies at second 30 — not because anything is wrong, but because a wall-clock cap can't tell a live long-running node from a hung one.

The fix is idle_timeout plus a heartbeat. Call runtime.heartbeat() as each chunk or batch arrives; that's the progress signal that resets the idle clock.

from langgraph.runtime import Runtime

async def long_running_node(state: State, runtime: Runtime) -> State:
    for batch in fetch_batches():
        process(batch)
        runtime.heartbeat()          # resets the idle clock on real progress
    return {"result": "done"}

builder.add_node(
    "long_running_node",
    long_running_node,
    timeout=TimeoutPolicy(idle_timeout=30, refresh_on="heartbeat"),
)

Now the node can run for ten minutes if it keeps making progress, and still dies within 30 seconds of actually stalling. That's the behavior you meant when you added the timeout in the first place.

run_timeout asks "has this taken too long?" idle_timeout asks "has this stopped?" For a streaming node, only the second question has a right answer.

How timeout, retry, and error handling compose#

The three fault-tolerance knobs stack in a fixed order, and knowing it saves you from over-engineering:

  1. Timeout fires → LangGraph raises NodeTimeoutError and discards any writes that attempt made, so partial state never leaks into your graph.
  2. Retry policy decides. NodeTimeoutError is just an exception, so your RetryPolicy sees it like any other failure. If attempts remain, it starts a fresh attempt.
  3. Error handler runs last — only after retries are exhausted.
builder.add_node(
    "call_llm",
    call_llm,
    retry_policy=RetryPolicy(max_attempts=4, backoff_factor=2.0),
    timeout=TimeoutPolicy(run_timeout=30, idle_timeout=5),
    error_handler=handle_model_failure,   # only if all 4 attempts fail
)

This is why you don't need to catch timeouts yourself. The timeout's job is to end a stuck attempt cleanly; the retry policy's job is to decide whether the stall was transient. Together they turn "the run hangs forever" into "the run retries twice and moves on" — which is the entire point. It's the same discipline that keeps an agent from spinning in a loop forever: put a hard bound on a single step, then decide what happens when the bound trips. And it pairs with the other half of agent fault tolerance — handling the tool call that returns a lie instead of an error, where the failure never raises at all.

The checklist#