Compare commits

...
Author SHA1 Message Date
Sydney Runkle a55365f3f9 simplify: drop _should_route_to_error_handler, remove functional API error handler, clean up prepare_node_error_handler_task signature 2026-05-11 16:22:10 -07:00
Sydney Runkle 2cd7ecc81e simplify: handlers always receive state as first arg, drop takes_input flag 2026-05-11 16:04:47 -07:00
Sydney Runkle f6746b39cc refactor(langgraph): implement error_handler as a policy field, not a hidden node
Replace the hidden __error_handler__<name> node approach with a
callable field on PregelNode/PregelExecutableTask, matching how
retry_policy and cache_policy work:

- error_handler: Runnable | None lives on StateNodeSpec, PregelNode,
  and PregelExecutableTask — no separate node registration at compile time
- ErrorHandlerNode type alias added to _node.py covering the common
  handler signatures (state + NodeError, runtime + NodeError, etc.)
- Handler task is synthesized at runtime in schedule_error_handler using
  the failed task's write pipeline (failed_task.writers), so no separate
  PregelNode is needed
- compile(error_handler=...) adds a graph-level fallback handler;
  per-node handlers take precedence
- @task(error_handler=...) in the functional API uses inline try/except
  wrapping so the caller's future always resolves to a value
- interrupt_before/after ["__error_handler__<node>"] still works via
  updated validate() and _validate.py checks
- RunnableCallable gains takes_input flag so handlers whose only
  parameters are injected kwargs (e.g. def h(error: NodeError) -> T)
  are called without a spurious positional input arg
2026-05-11 15:59:22 -07:00
10 changed files with 270 additions and 127 deletions
+25 -2
View File
@@ -8,6 +8,7 @@ from langchain_core.runnables import Runnable, RunnableConfig
from langgraph.store.base import BaseStore from langgraph.store.base import BaseStore
from langgraph._internal._typing import EMPTY_SEQ from langgraph._internal._typing import EMPTY_SEQ
from langgraph.errors import NodeError
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
from langgraph.types import CachePolicy, RetryPolicy, StreamWriter, TimeoutPolicy from langgraph.types import CachePolicy, RetryPolicy, StreamWriter, TimeoutPolicy
from langgraph.typing import ContextT, NodeInputT, NodeInputT_contra from langgraph.typing import ContextT, NodeInputT, NodeInputT_contra
@@ -64,6 +65,22 @@ class _NodeWithRuntime(Protocol[NodeInputT_contra, ContextT]):
) -> Any: ... ) -> Any: ...
class _NodeWithNodeError(Protocol[NodeInputT_contra]):
def __call__(self, state: NodeInputT_contra, *, error: NodeError) -> Any: ...
class _NodeWithConfigNodeError(Protocol[NodeInputT_contra]):
def __call__(
self, state: NodeInputT_contra, *, config: RunnableConfig, error: NodeError
) -> Any: ...
class _NodeWithRuntimeNodeError(Protocol[NodeInputT_contra, ContextT]):
def __call__(
self, state: NodeInputT_contra, *, runtime: Runtime[ContextT], error: NodeError
) -> Any: ...
# TODO: we probably don't want to explicitly support the config / store signatures once # TODO: we probably don't want to explicitly support the config / store signatures once
# we move to adding a context arg. Maybe what we do is we add support for kwargs with param spec # we move to adding a context arg. Maybe what we do is we add support for kwargs with param spec
# this is purely for typing purposes though, so can easily change in the coming weeks. # this is purely for typing purposes though, so can easily change in the coming weeks.
@@ -80,6 +97,13 @@ StateNode: TypeAlias = (
| Runnable[NodeInputT, Any] | Runnable[NodeInputT, Any]
) )
ErrorHandlerNode: TypeAlias = (
StateNode[NodeInputT, ContextT]
| _NodeWithNodeError[NodeInputT]
| _NodeWithConfigNodeError[NodeInputT]
| _NodeWithRuntimeNodeError[NodeInputT, ContextT]
)
@dataclass(slots=True) @dataclass(slots=True)
class StateNodeSpec(Generic[NodeInputT, ContextT]): class StateNodeSpec(Generic[NodeInputT, ContextT]):
@@ -88,8 +112,7 @@ class StateNodeSpec(Generic[NodeInputT, ContextT]):
input_schema: type[NodeInputT] input_schema: type[NodeInputT]
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None retry_policy: RetryPolicy | Sequence[RetryPolicy] | None
cache_policy: CachePolicy | None cache_policy: CachePolicy | None
is_error_handler: bool = False error_handler: Runnable[Any, Any] | None = None
error_handler_node: str | None = None
ends: tuple[str, ...] | dict[str, str] | None = EMPTY_SEQ ends: tuple[str, ...] | dict[str, str] | None = EMPTY_SEQ
defer: bool = False defer: bool = False
timeout: TimeoutPolicy | None = None timeout: TimeoutPolicy | None = None
+35 -30
View File
@@ -65,7 +65,7 @@ from langgraph.errors import (
create_error_message, create_error_message,
) )
from langgraph.graph._branch import BranchSpec from langgraph.graph._branch import BranchSpec
from langgraph.graph._node import StateNode, StateNodeSpec from langgraph.graph._node import ErrorHandlerNode, StateNode, StateNodeSpec
from langgraph.managed.base import ( from langgraph.managed.base import (
ManagedValueSpec, ManagedValueSpec,
is_managed_value, is_managed_value,
@@ -772,24 +772,15 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
if destinations is not None: if destinations is not None:
ends = destinations ends = destinations
resolved_input_schema: type[Any] = ( coerced_error_handler: Runnable[Any, Any] | None = (
input_schema or inferred_input_schema or self.state_schema coerce_to_runnable( # type: ignore[arg-type]
) error_handler,
handler_node_name: str | None = None name=f"__error_handler__{node}",
if error_handler is not None: trace=False,
handler_node_name = f"__error_handler__{node}"
if handler_node_name in self.nodes:
raise ValueError(
f"Auto-generated error handler node `{handler_node_name}` already exists."
)
self.nodes[handler_node_name] = StateNodeSpec[Any, ContextT](
coerce_to_runnable(error_handler, name=handler_node_name, trace=False), # type: ignore[arg-type]
metadata=None,
input_schema=resolved_input_schema,
retry_policy=None,
cache_policy=None,
is_error_handler=True,
) )
if error_handler is not None
else None
)
if input_schema is not None: if input_schema is not None:
self.nodes[node] = StateNodeSpec[NodeInputT, ContextT]( self.nodes[node] = StateNodeSpec[NodeInputT, ContextT](
@@ -798,7 +789,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
input_schema=input_schema, input_schema=input_schema,
retry_policy=retry_policy, retry_policy=retry_policy,
cache_policy=cache_policy, cache_policy=cache_policy,
error_handler_node=handler_node_name, error_handler=coerced_error_handler,
ends=ends, ends=ends,
defer=defer, defer=defer,
timeout=timeout, timeout=timeout,
@@ -810,7 +801,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
input_schema=inferred_input_schema, input_schema=inferred_input_schema,
retry_policy=retry_policy, retry_policy=retry_policy,
cache_policy=cache_policy, cache_policy=cache_policy,
error_handler_node=handler_node_name, error_handler=coerced_error_handler,
ends=ends, ends=ends,
defer=defer, defer=defer,
timeout=timeout, timeout=timeout,
@@ -822,7 +813,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
input_schema=self.state_schema, input_schema=self.state_schema,
retry_policy=retry_policy, retry_policy=retry_policy,
cache_policy=cache_policy, cache_policy=cache_policy,
error_handler_node=handler_node_name, error_handler=coerced_error_handler,
ends=ends, ends=ends,
defer=defer, defer=defer,
timeout=timeout, timeout=timeout,
@@ -1079,7 +1070,17 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
if interrupt: if interrupt:
for node in interrupt: for node in interrupt:
if node not in self.nodes: if node not in self.nodes:
raise ValueError(f"Interrupt node `{node}` not found") # __error_handler__<name> is a valid virtual task name when the
# base node has an error_handler configured.
if node.startswith("__error_handler__"):
base = node[len("__error_handler__"):]
if (
base not in self.nodes
or self.nodes[base].error_handler is None
):
raise ValueError(f"Interrupt node `{node}` not found")
else:
raise ValueError(f"Interrupt node `{node}` not found")
self.compiled = True self.compiled = True
return self return self
@@ -1094,6 +1095,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
debug: bool = False, debug: bool = False,
name: str | None = None, name: str | None = None,
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None, transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
error_handler: ErrorHandlerNode[Any, ContextT] | None = None,
) -> CompiledStateGraph[StateT, ContextT, InputT, OutputT]: ) -> CompiledStateGraph[StateT, ContextT, InputT, OutputT]:
"""Compiles the `StateGraph` into a `CompiledStateGraph` object. """Compiles the `StateGraph` into a `CompiledStateGraph` object.
@@ -1193,11 +1195,15 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
key for key, val in self.channels.items() if not is_managed_value(val) key for key, val in self.channels.items() if not is_managed_value(val)
] ]
) )
node_error_handler_map = { error_handler: Runnable[Any, Any] | None = (
node_name: spec.error_handler_node coerce_to_runnable( # type: ignore[arg-type]
for node_name, spec in self.nodes.items() error_handler,
if spec.error_handler_node is not None name="__graph_error_handler__",
} trace=False,
)
if error_handler is not None
else None
)
compiled = CompiledStateGraph[StateT, ContextT, InputT, OutputT]( compiled = CompiledStateGraph[StateT, ContextT, InputT, OutputT](
builder=self, builder=self,
@@ -1220,7 +1226,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
debug=debug, debug=debug,
store=store, store=store,
cache=cache, cache=cache,
node_error_handler_map=node_error_handler_map, error_handler=error_handler,
name=name or "LangGraph", name=name or "LangGraph",
stream_transformers=transformers, stream_transformers=transformers,
) )
@@ -1395,8 +1401,7 @@ class CompiledStateGraph(
metadata=node.metadata, metadata=node.metadata,
retry_policy=node.retry_policy, retry_policy=node.retry_policy,
cache_policy=node.cache_policy, cache_policy=node.cache_policy,
is_error_handler=node.is_error_handler, error_handler=node.error_handler,
error_handler_node=node.error_handler_node,
bound=node.runnable, # type: ignore[arg-type] bound=node.runnable, # type: ignore[arg-type]
timeout=node.timeout, timeout=node.timeout,
) )
+29 -39
View File
@@ -20,6 +20,7 @@ from typing import (
from langchain_core.callbacks import Callbacks from langchain_core.callbacks import Callbacks
from langchain_core.callbacks.manager import AsyncParentRunManager, ParentRunManager from langchain_core.callbacks.manager import AsyncParentRunManager, ParentRunManager
from langchain_core.runnables import Runnable
from langchain_core.runnables.config import RunnableConfig from langchain_core.runnables.config import RunnableConfig
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
BaseCheckpointSaver, BaseCheckpointSaver,
@@ -32,6 +33,7 @@ from langgraph.store.base import BaseStore
from xxhash import xxh3_128_hexdigest from xxhash import xxh3_128_hexdigest
from langgraph._internal._config import merge_configs, patch_config from langgraph._internal._config import merge_configs, patch_config
from langgraph._internal._runnable import RunnableSeq
from langgraph._internal._constants import ( from langgraph._internal._constants import (
CACHE_NS_WRITES, CACHE_NS_WRITES,
CONF, CONF,
@@ -71,6 +73,7 @@ from langgraph.constants import TAG_HIDDEN
from langgraph.errors import NodeError from langgraph.errors import NodeError
from langgraph.managed.base import ManagedValueMapping from langgraph.managed.base import ManagedValueMapping
from langgraph.pregel._call import get_runnable_for_task, identifier from langgraph.pregel._call import get_runnable_for_task, identifier
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.pregel._io import read_channels from langgraph.pregel._io import read_channels
from langgraph.pregel._log import logger from langgraph.pregel._log import logger
from langgraph.pregel._read import INPUT_CACHE_KEY_TYPE, PregelNode from langgraph.pregel._read import INPUT_CACHE_KEY_TYPE, PregelNode
@@ -407,6 +410,7 @@ def prepare_next_tasks(
updated_channels: set[str] | None = None, updated_channels: set[str] | None = None,
retry_policy: Sequence[RetryPolicy] = (), retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None, cache_policy: CachePolicy | None = None,
error_handler: Runnable[Any, Any] | None = None,
) -> dict[str, PregelTask] | dict[str, PregelExecutableTask]: ) -> dict[str, PregelTask] | dict[str, PregelExecutableTask]:
"""Prepare the set of tasks that will make up the next Pregel step. """Prepare the set of tasks that will make up the next Pregel step.
@@ -462,6 +466,7 @@ def prepare_next_tasks(
input_cache=input_cache, input_cache=input_cache,
cache_policy=cache_policy, cache_policy=cache_policy,
retry_policy=retry_policy, retry_policy=retry_policy,
error_handler=error_handler,
): ):
tasks.append(task) tasks.append(task)
@@ -508,6 +513,7 @@ def prepare_next_tasks(
input_cache=input_cache, input_cache=input_cache,
cache_policy=cache_policy, cache_policy=cache_policy,
retry_policy=retry_policy, retry_policy=retry_policy,
error_handler=error_handler,
): ):
tasks.append(task) tasks.append(task)
return {t.id: t for t in tasks} return {t.id: t for t in tasks}
@@ -542,6 +548,7 @@ def prepare_single_task(
input_cache: dict[INPUT_CACHE_KEY_TYPE, Any] | None = None, input_cache: dict[INPUT_CACHE_KEY_TYPE, Any] | None = None,
cache_policy: CachePolicy | None = None, cache_policy: CachePolicy | None = None,
retry_policy: Sequence[RetryPolicy] = (), retry_policy: Sequence[RetryPolicy] = (),
error_handler: Runnable[Any, Any] | None = None,
) -> None | PregelTask | PregelExecutableTask: ) -> None | PregelTask | PregelExecutableTask:
"""Prepares a single task for the next Pregel step, given a task path, which """Prepares a single task for the next Pregel step, given a task path, which
uniquely identifies a PUSH or PULL task within the graph.""" uniquely identifies a PUSH or PULL task within the graph."""
@@ -756,6 +763,7 @@ def prepare_single_task(
writers=proc.flat_writers, writers=proc.flat_writers,
subgraphs=proc.subgraphs, subgraphs=proc.subgraphs,
timeout=proc.timeout, timeout=proc.timeout,
error_handler=proc.error_handler or error_handler,
) )
else: else:
return PregelTask(task_id, name, task_path[:3]) return PregelTask(task_id, name, task_path[:3])
@@ -1110,11 +1118,10 @@ def prepare_push_task_send(
def prepare_node_error_handler_task( def prepare_node_error_handler_task(
failed_task: PregelExecutableTask, failed_task: PregelExecutableTask,
*, *,
handler_node_name: str, handler: Runnable,
failed_error: BaseException, failed_error: BaseException,
checkpoint: Checkpoint, checkpoint: Checkpoint,
pending_writes: list[PendingWrite], pending_writes: list[PendingWrite],
processes: Mapping[str, PregelNode],
channels: Mapping[str, BaseChannel], channels: Mapping[str, BaseChannel],
managed: ManagedValueMapping, managed: ManagedValueMapping,
config: RunnableConfig, config: RunnableConfig,
@@ -1123,17 +1130,14 @@ def prepare_node_error_handler_task(
store: BaseStore | None = None, store: BaseStore | None = None,
checkpointer: BaseCheckpointSaver | None = None, checkpointer: BaseCheckpointSaver | None = None,
manager: None | ParentRunManager | AsyncParentRunManager = None, manager: None | ParentRunManager | AsyncParentRunManager = None,
cache_policy: CachePolicy | None = None,
retry_policy: Sequence[RetryPolicy] = (), retry_policy: Sequence[RetryPolicy] = (),
) -> PregelExecutableTask | None: ) -> PregelExecutableTask:
"""Prepare an immediate node-level error handler task for a failed task.""" """Prepare an error handler task for a failed task.
if handler_node_name not in processes:
return None
proc = processes[handler_node_name]
proc_node = proc.node
if proc_node is None:
return None
The handler borrows the failed task's write pipeline (same state channels),
so no separate node registration is needed.
"""
handler_node_name = f"__error_handler__{failed_task.name}"
checkpoint_id_bytes = binascii.unhexlify(checkpoint["id"].replace("-", "")) checkpoint_id_bytes = binascii.unhexlify(checkpoint["id"].replace("-", ""))
task_id_func = _xxhash_str if checkpoint["v"] > 1 else _uuid5_str task_id_func = _xxhash_str if checkpoint["v"] > 1 else _uuid5_str
configurable = config.get(CONF, {}) configurable = config.get(CONF, {})
@@ -1159,27 +1163,17 @@ def prepare_node_error_handler_task(
"langgraph_path": translated_task_path, "langgraph_path": translated_task_path,
"langgraph_checkpoint_ns": task_checkpoint_ns, "langgraph_checkpoint_ns": task_checkpoint_ns,
} }
if proc.metadata:
metadata.update(proc.metadata)
writes: deque[tuple[str, Any]] = deque() writes: deque[tuple[str, Any]] = deque()
# Mirror how regular node procs are built: combine handler with a write pipeline
effective_retry_policy = proc.retry_policy or retry_policy # so run_with_retry invokes the full pipeline in one shot.
effective_cache_policy = proc.cache_policy or cache_policy # - PULL node tasks: writers are in failed_task.writers → reuse them
if effective_cache_policy: # - PUSH functional tasks: writers are embedded in proc (empty writers list) →
args_key = effective_cache_policy.key_func(failed_task.input) # add a RETURN write so the handler's result becomes the future's value.
cache_key = CacheKey( handler_writers = failed_task.writers
( if handler_writers:
CACHE_NS_WRITES, handler_proc: Runnable = RunnableSeq(handler, *handler_writers)
(identifier(proc) or "__dynamic__"),
handler_node_name,
),
xxh3_128_hexdigest(
args_key.encode() if isinstance(args_key, str) else args_key
),
effective_cache_policy.ttl,
)
else: else:
cache_key = None handler_proc = RunnableSeq(handler, ChannelWrite([ChannelWriteEntry(RETURN)]))
scratchpad = _scratchpad( scratchpad = _scratchpad(
config[CONF].get(CONFIG_KEY_SCRATCHPAD), config[CONF].get(CONFIG_KEY_SCRATCHPAD),
@@ -1194,14 +1188,11 @@ def prepare_node_error_handler_task(
runtime = runtime.override( runtime = runtime.override(
store=store, previous=checkpoint["channel_values"].get(PREVIOUS, None) store=store, previous=checkpoint["channel_values"].get(PREVIOUS, None)
) )
additional_config: RunnableConfig = { additional_config: RunnableConfig = {"metadata": metadata}
"metadata": metadata,
"tags": proc.tags,
}
return PregelExecutableTask( return PregelExecutableTask(
handler_node_name, handler_node_name,
failed_task.input, failed_task.input,
proc_node, handler_proc,
writes, writes,
patch_config( patch_config(
merge_configs(config, additional_config), merge_configs(config, additional_config),
@@ -1239,12 +1230,11 @@ def prepare_node_error_handler_task(
}, },
), ),
PUSH_TRIGGER, PUSH_TRIGGER,
effective_retry_policy, retry_policy,
cache_key, None, # handlers don't cache
task_id, task_id,
translated_task_path, translated_task_path,
writers=proc.flat_writers, writers=handler_writers, # for ParentCommand / subgraph routing
subgraphs=proc.subgraphs,
) )
+20 -23
View File
@@ -22,7 +22,8 @@ from typing import (
) )
from langchain_core.callbacks import AsyncParentRunManager, ParentRunManager from langchain_core.callbacks import AsyncParentRunManager, ParentRunManager
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import Runnable, RunnableConfig
from langgraph.cache.base import BaseCache from langgraph.cache.base import BaseCache
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
WRITES_IDX_MAP, WRITES_IDX_MAP,
@@ -279,6 +280,7 @@ class PregelLoop:
migrate_checkpoint: Callable[[Checkpoint], None] | None = None, migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
retry_policy: Sequence[RetryPolicy] = (), retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None, cache_policy: CachePolicy | None = None,
error_handler: Runnable[Any, Any] | None = None,
has_graph_lifecycle_callbacks: bool = False, has_graph_lifecycle_callbacks: bool = False,
) -> None: ) -> None:
self.stream = stream self.stream = stream
@@ -303,6 +305,7 @@ class PregelLoop:
self.trigger_to_nodes = trigger_to_nodes self.trigger_to_nodes = trigger_to_nodes
self.retry_policy = retry_policy self.retry_policy = retry_policy
self.cache_policy = cache_policy self.cache_policy = cache_policy
self.error_handler = error_handler
self.durability = durability self.durability = durability
self._has_graph_lifecycle_callbacks = has_graph_lifecycle_callbacks self._has_graph_lifecycle_callbacks = has_graph_lifecycle_callbacks
self._graph_lifecycle_events = deque() self._graph_lifecycle_events = deque()
@@ -545,6 +548,7 @@ class PregelLoop:
manager=self.manager, manager=self.manager,
retry_policy=self.retry_policy, retry_policy=self.retry_policy,
cache_policy=self.cache_policy, cache_policy=self.cache_policy,
error_handler=self.error_handler,
), ),
): ):
# produce debug output # produce debug output
@@ -597,6 +601,7 @@ class PregelLoop:
updated_channels=self.updated_channels, updated_channels=self.updated_channels,
retry_policy=self.retry_policy, retry_policy=self.retry_policy,
cache_policy=self.cache_policy, cache_policy=self.cache_policy,
error_handler=self.error_handler,
) )
# produce debug output # produce debug output
@@ -1368,6 +1373,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
migrate_checkpoint: Callable[[Checkpoint], None] | None = None, migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
retry_policy: Sequence[RetryPolicy] = (), retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None, cache_policy: CachePolicy | None = None,
error_handler: Runnable[Any, Any] | None = None,
has_graph_lifecycle_callbacks: bool = False, has_graph_lifecycle_callbacks: bool = False,
) -> None: ) -> None:
super().__init__( super().__init__(
@@ -1389,6 +1395,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
trigger_to_nodes=trigger_to_nodes, trigger_to_nodes=trigger_to_nodes,
retry_policy=retry_policy, retry_policy=retry_policy,
cache_policy=cache_policy, cache_policy=cache_policy,
error_handler=error_handler,
durability=durability, durability=durability,
has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks, has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
) )
@@ -1451,22 +1458,18 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
def schedule_error_handler( def schedule_error_handler(
self, failed_task: PregelExecutableTask, error: BaseException self, failed_task: PregelExecutableTask, error: BaseException
) -> PregelExecutableTask | None: ) -> PregelExecutableTask | None:
handler_node = self.nodes[failed_task.name].error_handler_node handler = failed_task.error_handler or self.error_handler
if not handler_node: if handler is None:
return None return None
writes = list(failed_task.writes) writes = list(failed_task.writes)
writes.append((ERROR_SOURCE_NODE, failed_task.name)) writes.append((ERROR_SOURCE_NODE, failed_task.name))
self.put_writes( self.put_writes(failed_task.id, writes)
failed_task.id,
writes,
)
handler_task = prepare_node_error_handler_task( handler_task = prepare_node_error_handler_task(
failed_task, failed_task,
handler_node_name=handler_node, handler=handler,
failed_error=error, failed_error=error,
checkpoint=self.checkpoint, checkpoint=self.checkpoint,
pending_writes=self.checkpoint_pending_writes, pending_writes=self.checkpoint_pending_writes,
processes=self.nodes,
channels=self.channels, channels=self.channels,
managed=self.managed, managed=self.managed,
config=failed_task.config, config=failed_task.config,
@@ -1476,10 +1479,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
checkpointer=self.checkpointer, checkpointer=self.checkpointer,
manager=self.manager, manager=self.manager,
retry_policy=self.retry_policy, retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
) )
if handler_task is None:
return None
self.tasks[handler_task.id] = handler_task self.tasks[handler_task.id] = handler_task
if not self.is_replaying: if not self.is_replaying:
self._match_writes({handler_task.id: handler_task}) self._match_writes({handler_task.id: handler_task})
@@ -1487,6 +1487,8 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
self.output_writes(task.id, task.writes, cached=True) self.output_writes(task.id, task.writes, cached=True)
return handler_task return handler_task
def put_writes(self, task_id: str, writes: WritesT) -> None: def put_writes(self, task_id: str, writes: WritesT) -> None:
"""Put writes for a task, to be read by the next tick.""" """Put writes for a task, to be read by the next tick."""
super().put_writes(task_id, writes) super().put_writes(task_id, writes)
@@ -1621,6 +1623,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
migrate_checkpoint: Callable[[Checkpoint], None] | None = None, migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
retry_policy: Sequence[RetryPolicy] = (), retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None, cache_policy: CachePolicy | None = None,
error_handler: Runnable[Any, Any] | None = None,
has_graph_lifecycle_callbacks: bool = False, has_graph_lifecycle_callbacks: bool = False,
) -> None: ) -> None:
super().__init__( super().__init__(
@@ -1642,6 +1645,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
trigger_to_nodes=trigger_to_nodes, trigger_to_nodes=trigger_to_nodes,
retry_policy=retry_policy, retry_policy=retry_policy,
cache_policy=cache_policy, cache_policy=cache_policy,
error_handler=error_handler,
durability=durability, durability=durability,
has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks, has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
) )
@@ -1706,22 +1710,18 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
async def aschedule_error_handler( async def aschedule_error_handler(
self, failed_task: PregelExecutableTask, error: BaseException self, failed_task: PregelExecutableTask, error: BaseException
) -> PregelExecutableTask | None: ) -> PregelExecutableTask | None:
handler_node = self.nodes[failed_task.name].error_handler_node handler = failed_task.error_handler or self.error_handler
if not handler_node: if handler is None:
return None return None
writes = list(failed_task.writes) writes = list(failed_task.writes)
writes.append((ERROR_SOURCE_NODE, failed_task.name)) writes.append((ERROR_SOURCE_NODE, failed_task.name))
self.put_writes( self.put_writes(failed_task.id, writes)
failed_task.id,
writes,
)
handler_task = prepare_node_error_handler_task( handler_task = prepare_node_error_handler_task(
failed_task, failed_task,
handler_node_name=handler_node, handler=handler,
failed_error=error, failed_error=error,
checkpoint=self.checkpoint, checkpoint=self.checkpoint,
pending_writes=self.checkpoint_pending_writes, pending_writes=self.checkpoint_pending_writes,
processes=self.nodes,
channels=self.channels, channels=self.channels,
managed=self.managed, managed=self.managed,
config=failed_task.config, config=failed_task.config,
@@ -1731,10 +1731,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
checkpointer=self.checkpointer, checkpointer=self.checkpointer,
manager=self.manager, manager=self.manager,
retry_policy=self.retry_policy, retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
) )
if handler_task is None:
return None
self.tasks[handler_task.id] = handler_task self.tasks[handler_task.id] = handler_task
if not self.is_replaying: if not self.is_replaying:
self._match_writes({handler_task.id: handler_task}) self._match_writes({handler_task.id: handler_task})
+4 -9
View File
@@ -138,11 +138,8 @@ class PregelNode:
metadata: Mapping[str, Any] | None metadata: Mapping[str, Any] | None
"""Metadata to attach to the node for tracing.""" """Metadata to attach to the node for tracing."""
is_error_handler: bool error_handler: Runnable[Any, Any] | None
"""Whether this node is registered as an error handler node.""" """Callable invoked after retries are exhausted; receives same input as the node."""
error_handler_node: str | None
"""Optional handler node name for failures from this node."""
subgraphs: Sequence[PregelProtocol] subgraphs: Sequence[PregelProtocol]
"""Subgraphs used by the node.""" """Subgraphs used by the node."""
@@ -159,8 +156,7 @@ class PregelNode:
bound: Runnable[Any, Any] | None = None, bound: Runnable[Any, Any] | None = None,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None, retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None, cache_policy: CachePolicy | None = None,
is_error_handler: bool = False, error_handler: Runnable[Any, Any] | None = None,
error_handler_node: str | None = None,
subgraphs: Sequence[PregelProtocol] | None = None, subgraphs: Sequence[PregelProtocol] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None, timeout: float | timedelta | TimeoutPolicy | None = None,
) -> None: ) -> None:
@@ -177,8 +173,7 @@ class PregelNode:
self.timeout = coerce_timeout_policy(timeout) self.timeout = coerce_timeout_policy(timeout)
self.tags = tags self.tags = tags
self.metadata = metadata self.metadata = metadata
self.is_error_handler = is_error_handler self.error_handler = error_handler
self.error_handler_node = error_handler_node
if subgraphs is not None: if subgraphs is not None:
self.subgraphs = subgraphs self.subgraphs = subgraphs
elif self.bound is not DEFAULT_BOUND: elif self.bound is not DEFAULT_BOUND:
+5 -17
View File
@@ -13,7 +13,6 @@ from collections.abc import (
Collection, Collection,
Iterable, Iterable,
Iterator, Iterator,
Mapping,
Sequence, Sequence,
) )
from functools import partial from functools import partial
@@ -143,7 +142,6 @@ class PregelRunner:
put_writes: weakref.ref[Callable[[str, Sequence[tuple[str, Any]]], None]], put_writes: weakref.ref[Callable[[str, Sequence[tuple[str, Any]]], None]],
use_astream: bool = False, use_astream: bool = False,
node_finished: Callable[[str], None] | None = None, node_finished: Callable[[str], None] | None = None,
node_error_handler_map: Mapping[str, str] | None = None,
schedule_error_handler: Callable[ schedule_error_handler: Callable[
[PregelExecutableTask, BaseException], PregelExecutableTask | None [PregelExecutableTask, BaseException], PregelExecutableTask | None
] ]
@@ -158,20 +156,10 @@ class PregelRunner:
self.put_writes = put_writes self.put_writes = put_writes
self.use_astream = use_astream self.use_astream = use_astream
self.node_finished = node_finished self.node_finished = node_finished
self.node_error_handler_map = dict(node_error_handler_map or {})
self.error_handler_nodes = set(self.node_error_handler_map.values())
self.schedule_error_handler = schedule_error_handler self.schedule_error_handler = schedule_error_handler
self.aschedule_error_handler = aschedule_error_handler self.aschedule_error_handler = aschedule_error_handler
# Exception object ids that are already routed to graph-level error handler.
# These ids are consulted by stop/panic checks to avoid re-raising handled
# exceptions via the normal fatal path in the same run.
self._handled_exception_ids: set[int] = set() self._handled_exception_ids: set[int] = set()
def _should_route_to_error_handler(self, task: PregelExecutableTask) -> bool:
if task.name in self.error_handler_nodes:
return False
return task.name in self.node_error_handler_map
def tick( def tick(
self, self,
tasks: Iterable[PregelExecutableTask], tasks: Iterable[PregelExecutableTask],
@@ -222,7 +210,7 @@ class PregelRunner:
self.commit(t, exc) self.commit(t, exc)
if ( if (
not isinstance(exc, GraphBubbleUp) not isinstance(exc, GraphBubbleUp)
and self._should_route_to_error_handler(t) and t.error_handler is not None
and self.schedule_error_handler is not None and self.schedule_error_handler is not None
): ):
self._handled_exception_ids.add(id(exc)) self._handled_exception_ids.add(id(exc))
@@ -295,7 +283,7 @@ class PregelRunner:
futures[get_waiter()] = None futures[get_waiter()] = None
elif ( elif (
(task_exc := _exception(fut)) (task_exc := _exception(fut))
and self._should_route_to_error_handler(task) and task.error_handler is not None
and not isinstance(task_exc, GraphBubbleUp) and not isinstance(task_exc, GraphBubbleUp)
): ):
self._handled_exception_ids.add(id(task_exc)) self._handled_exception_ids.add(id(task_exc))
@@ -414,7 +402,7 @@ class PregelRunner:
self.commit(t, exc) self.commit(t, exc)
if ( if (
not isinstance(exc, GraphBubbleUp) not isinstance(exc, GraphBubbleUp)
and self._should_route_to_error_handler(t) and t.error_handler is not None
and self.aschedule_error_handler is not None and self.aschedule_error_handler is not None
): ):
self._handled_exception_ids.add(id(exc)) self._handled_exception_ids.add(id(exc))
@@ -494,7 +482,7 @@ class PregelRunner:
futures[get_waiter()] = None futures[get_waiter()] = None
elif ( elif (
(task_exc := _exception(fut)) (task_exc := _exception(fut))
and self._should_route_to_error_handler(task) and task.error_handler is not None
and not isinstance(task_exc, GraphBubbleUp) and not isinstance(task_exc, GraphBubbleUp)
): ):
self._handled_exception_ids.add(id(task_exc)) self._handled_exception_ids.add(id(task_exc))
@@ -594,7 +582,7 @@ class PregelRunner:
else: else:
# save error to checkpointer # save error to checkpointer
task.writes.append((ERROR, exception)) task.writes.append((ERROR, exception))
if self._should_route_to_error_handler(task) and not isinstance( if task.error_handler is not None and not isinstance(
exception, GraphBubbleUp exception, GraphBubbleUp
): ):
# Mark early in commit path; loop-side routing may happen later. # Mark early in commit path; loop-side routing may happen later.
+9 -2
View File
@@ -99,14 +99,21 @@ def validate_graph(
if interrupt_after_nodes != "*": if interrupt_after_nodes != "*":
for n in interrupt_after_nodes: for n in interrupt_after_nodes:
if n not in nodes: if n not in nodes and not _is_valid_error_handler_interrupt(n, nodes):
raise ValueError(f"Node {n} not in nodes") raise ValueError(f"Node {n} not in nodes")
if interrupt_before_nodes != "*": if interrupt_before_nodes != "*":
for n in interrupt_before_nodes: for n in interrupt_before_nodes:
if n not in nodes: if n not in nodes and not _is_valid_error_handler_interrupt(n, nodes):
raise ValueError(f"Node {n} not in nodes") raise ValueError(f"Node {n} not in nodes")
def _is_valid_error_handler_interrupt(name: str, nodes: Mapping[str, PregelNode]) -> bool:
if not name.startswith("__error_handler__"):
return False
base = name[len("__error_handler__"):]
return base in nodes and nodes[base].error_handler is not None
def validate_keys( def validate_keys(
keys: str | Sequence[str] | None, keys: str | Sequence[str] | None,
channels: Mapping[str, Any], channels: Mapping[str, Any],
+6 -5
View File
@@ -33,6 +33,7 @@ from uuid import UUID, uuid5
from langchain_core._api import beta from langchain_core._api import beta
from langchain_core.globals import get_debug from langchain_core.globals import get_debug
from langchain_core.runnables import ( from langchain_core.runnables import (
Runnable,
RunnableSequence, RunnableSequence,
) )
from langchain_core.runnables.base import Input, Output from langchain_core.runnables.base import Input, Output
@@ -751,7 +752,7 @@ class Pregel(
name: str = "LangGraph" name: str = "LangGraph"
trigger_to_nodes: Mapping[str, Sequence[str]] trigger_to_nodes: Mapping[str, Sequence[str]]
node_error_handler_map: Mapping[str, str] error_handler: Runnable[Any, Any] | None
def __init__( def __init__(
self, self,
@@ -776,7 +777,7 @@ class Pregel(
context_schema: type[ContextT] | None = None, context_schema: type[ContextT] | None = None,
config: RunnableConfig | None = None, config: RunnableConfig | None = None,
trigger_to_nodes: Mapping[str, Sequence[str]] | None = None, trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
node_error_handler_map: Mapping[str, str] | None = None, error_handler: Runnable[Any, Any] | None = None,
name: str = "LangGraph", name: str = "LangGraph",
stream_transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None, stream_transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
**deprecated_kwargs: Unpack[DeprecatedKwargs], **deprecated_kwargs: Unpack[DeprecatedKwargs],
@@ -824,7 +825,7 @@ class Pregel(
self.context_schema = context_schema self.context_schema = context_schema
self.config = config self.config = config
self.trigger_to_nodes = trigger_to_nodes or {} self.trigger_to_nodes = trigger_to_nodes or {}
self.node_error_handler_map = node_error_handler_map or {} self.error_handler = error_handler
self.name = name self.name = name
self.stream_transformers: tuple[Callable[[tuple[str, ...]], Any], ...] = tuple( self.stream_transformers: tuple[Callable[[tuple[str, ...]], Any], ...] = tuple(
stream_transformers or () stream_transformers or ()
@@ -2885,6 +2886,7 @@ class Pregel(
migrate_checkpoint=self._migrate_checkpoint, migrate_checkpoint=self._migrate_checkpoint,
retry_policy=self.retry_policy, retry_policy=self.retry_policy,
cache_policy=self.cache_policy, cache_policy=self.cache_policy,
error_handler=self.error_handler,
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers), has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
) as loop: ) as loop:
emit_graph_lifecycle_events(loop) emit_graph_lifecycle_events(loop)
@@ -2895,7 +2897,6 @@ class Pregel(
), ),
put_writes=weakref.WeakMethod(loop.put_writes), put_writes=weakref.WeakMethod(loop.put_writes),
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED), node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
node_error_handler_map=self.node_error_handler_map,
schedule_error_handler=loop.schedule_error_handler, schedule_error_handler=loop.schedule_error_handler,
) )
# enable subgraph streaming # enable subgraph streaming
@@ -3337,6 +3338,7 @@ class Pregel(
migrate_checkpoint=self._migrate_checkpoint, migrate_checkpoint=self._migrate_checkpoint,
retry_policy=self.retry_policy, retry_policy=self.retry_policy,
cache_policy=self.cache_policy, cache_policy=self.cache_policy,
error_handler=self.error_handler,
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers), has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
) as loop: ) as loop:
await aemit_graph_lifecycle_events(loop) await aemit_graph_lifecycle_events(loop)
@@ -3348,7 +3350,6 @@ class Pregel(
put_writes=weakref.WeakMethod(loop.put_writes), put_writes=weakref.WeakMethod(loop.put_writes),
use_astream=do_stream, use_astream=do_stream,
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED), node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
node_error_handler_map=self.node_error_handler_map,
aschedule_error_handler=loop.aschedule_error_handler, aschedule_error_handler=loop.aschedule_error_handler,
) )
# enable subgraph streaming # enable subgraph streaming
+1
View File
@@ -628,6 +628,7 @@ class PregelExecutableTask:
writers: Sequence[Runnable] = () writers: Sequence[Runnable] = ()
subgraphs: Sequence[PregelProtocol] = () subgraphs: Sequence[PregelProtocol] = ()
timeout: TimeoutPolicy | None = None timeout: TimeoutPolicy | None = None
error_handler: Runnable | None = None
class StateSnapshot(NamedTuple): class StateSnapshot(NamedTuple):
+136
View File
@@ -2280,3 +2280,139 @@ def test_node_without_error_handler_still_fails_run():
with pytest.raises(ValueError, match="no handler"): with pytest.raises(ValueError, match="no handler"):
graph.invoke({"foo": ""}) graph.invoke({"foo": ""})
# ---------------------------------------------------------------------------
# Structural invariants from the policy-style refactor
# ---------------------------------------------------------------------------
def test_error_handler_not_registered_as_node():
"""After compile, no hidden __error_handler__* nodes should exist in the graph."""
class State(TypedDict):
foo: str
def failing_node(state: State) -> State:
raise ValueError("boom")
def handler(state: State, error: NodeError) -> State:
return {"foo": "handled"}
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, error_handler=handler)
.add_edge(START, "failing_node")
.compile()
)
hidden = [k for k in graph.nodes if k.startswith("__error_handler__")]
assert hidden == [], f"unexpected hidden nodes: {hidden}"
def test_error_handler_stored_on_pregel_node():
"""The error_handler callable should be a Runnable field on PregelNode, not a name pointer."""
class State(TypedDict):
foo: str
def failing_node(state: State) -> State:
raise ValueError("boom")
def handler(state: State) -> State:
return {"foo": "handled"}
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, error_handler=handler)
.add_edge(START, "failing_node")
.compile()
)
pregel_node = graph.nodes["failing_node"]
assert pregel_node.error_handler is not None, "error_handler should be set on PregelNode"
assert not hasattr(pregel_node, "error_handler_node"), "old string-pointer field should be gone"
assert not hasattr(pregel_node, "is_error_handler"), "is_error_handler flag should be gone"
def test_error_handler_dispatched_from_task_field():
"""error_handler on PregelExecutableTask drives dispatch — no node-map lookup needed."""
class State(TypedDict):
foo: str
def failing_node(state: State) -> State:
raise ValueError("boom")
def handler(state: State) -> State:
return {"foo": "handled"}
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, error_handler=handler)
.add_edge(START, "failing_node")
.compile()
)
result = graph.invoke({"foo": ""})
assert result["foo"] == "handled"
# ---------------------------------------------------------------------------
# Graph-level error handler
# ---------------------------------------------------------------------------
def test_graph_level_error_handler_used_when_no_per_node_handler():
"""compile(error_handler=fallback) should catch failures from nodes without their own handler."""
class State(TypedDict):
foo: str
def failing_node(state: State) -> State:
raise RuntimeError("node failed")
def graph_handler(state: State, error: NodeError) -> State:
return {"foo": f"graph_handler_caught:{error.node}"}
graph = (
StateGraph(State)
.add_node("failing_node", failing_node)
.add_edge(START, "failing_node")
.compile(error_handler=graph_handler)
)
result = graph.invoke({"foo": ""})
assert result["foo"] == "graph_handler_caught:failing_node"
def test_per_node_handler_takes_precedence_over_graph_level():
"""When a node has its own error_handler, it should win over the graph-level fallback."""
class State(TypedDict):
foo: str
def failing_node(state: State) -> State:
raise RuntimeError("node failed")
def node_handler(state: State, error: NodeError) -> State:
return {"foo": "node_handler"}
def graph_handler(state: State, error: NodeError) -> State:
return {"foo": "graph_handler"}
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, error_handler=node_handler)
.add_edge(START, "failing_node")
.compile(error_handler=graph_handler)
)
result = graph.invoke({"foo": ""})
assert result["foo"] == "node_handler"
# ---------------------------------------------------------------------------
# Functional API
# ---------------------------------------------------------------------------