pregel: Support retry policy per node

- to expose from StateGraph in future PR
This commit is contained in:
Nuno Campos
2024-07-17 13:17:15 -07:00
parent a184c7f23a
commit e4bfcffed4
6 changed files with 96 additions and 112 deletions
+14 -9
View File
@@ -570,6 +570,7 @@ class Pregel(
deque(), deque(),
None, None,
[INTERRUPT], [INTERRUPT],
None,
str(uuid5(UUID(checkpoint["id"]), INTERRUPT)), str(uuid5(UUID(checkpoint["id"]), INTERRUPT)),
) )
# execute task # execute task
@@ -661,6 +662,7 @@ class Pregel(
deque(), deque(),
None, None,
[INTERRUPT], [INTERRUPT],
None,
str(uuid5(UUID(checkpoint["id"]), INTERRUPT)), str(uuid5(UUID(checkpoint["id"]), INTERRUPT)),
) )
# execute task # execute task
@@ -1112,8 +1114,8 @@ class Pregel(
# combine pending writes from all tasks # combine pending writes from all tasks
pending_writes = deque[tuple[str, Any]]() pending_writes = deque[tuple[str, Any]]()
for _, _, _, writes, _, _, _ in next_tasks: for task in next_tasks:
pending_writes.extend(writes) pending_writes.extend(task.writes)
if debug: if debug:
print_step_writes( print_step_writes(
@@ -1560,8 +1562,8 @@ class Pregel(
# combine pending writes from all tasks # combine pending writes from all tasks
pending_writes = deque[tuple[str, Any]]() pending_writes = deque[tuple[str, Any]]()
for _, _, _, writes, _, _, _ in next_tasks: for task in next_tasks:
pending_writes.extend(writes) pending_writes.extend(task.writes)
if debug: if debug:
print_step_writes( print_step_writes(
@@ -1782,12 +1784,12 @@ def _should_interrupt(
) )
# and any triggered node is in interrupt_nodes list # and any triggered node is in interrupt_nodes list
and any( and any(
node task.name
for node, _, _, _, config, _, _ in tasks for task in tasks
if ( if (
(not config or TAG_HIDDEN not in config.get("tags")) (not task.config or TAG_HIDDEN not in task.config.get("tags"))
if interrupt_nodes == "*" if interrupt_nodes == "*"
else node in interrupt_nodes else task.name in interrupt_nodes
) )
) )
) )
@@ -1936,7 +1938,8 @@ def _prepare_next_tasks(
logger.warn(f"Ignoring invalid packet type {type(packet)} in pending sends") logger.warn(f"Ignoring invalid packet type {type(packet)} in pending sends")
continue continue
if for_execution: if for_execution:
if node := processes[packet.node].get_node(): proc = processes[packet.node]
if node := proc.get_node():
triggers = [TASKS] triggers = [TASKS]
metadata = { metadata = {
"langgraph_step": step, "langgraph_step": step,
@@ -1975,6 +1978,7 @@ def _prepare_next_tasks(
}, },
), ),
triggers, triggers,
proc.retry_policy,
task_id, task_id,
) )
) )
@@ -2061,6 +2065,7 @@ def _prepare_next_tasks(
}, },
), ),
triggers, triggers,
proc.retry_policy,
task_id, task_id,
) )
) )
+4 -4
View File
@@ -66,7 +66,7 @@ def map_debug_tasks(
step: int, tasks: list[PregelExecutableTask] step: int, tasks: list[PregelExecutableTask]
) -> Iterator[DebugOutputTask]: ) -> Iterator[DebugOutputTask]:
ts = datetime.now(timezone.utc).isoformat() ts = datetime.now(timezone.utc).isoformat()
for name, input, _, _, config, triggers, _ in tasks: for name, input, _, _, config, triggers, _, _ in tasks:
if config is not None and TAG_HIDDEN in config.get("tags", []): if config is not None and TAG_HIDDEN in config.get("tags", []):
continue continue
@@ -91,7 +91,7 @@ def map_debug_task_results(
stream_channels_list: Sequence[str], stream_channels_list: Sequence[str],
) -> Iterator[DebugOutputTaskResult]: ) -> Iterator[DebugOutputTaskResult]:
ts = datetime.now(timezone.utc).isoformat() ts = datetime.now(timezone.utc).isoformat()
for name, _, _, writes, config, _, _ in tasks: for name, _, _, writes, config, _, _, _ in tasks:
if config is not None and TAG_HIDDEN in config.get("tags", []): if config is not None and TAG_HIDDEN in config.get("tags", []):
continue continue
@@ -137,8 +137,8 @@ def print_step_tasks(step: int, next_tasks: list[PregelExecutableTask]) -> None:
f"Starting step {step} with {n_tasks} task{'s' if n_tasks != 1 else ''}:\n" f"Starting step {step} with {n_tasks} task{'s' if n_tasks != 1 else ''}:\n"
) )
+ "\n".join( + "\n".join(
f"- {get_colored_text(name, 'green')} -> {pformat(val)}" f"- {get_colored_text(task.name, 'green')} -> {pformat(task.input)}"
for name, val, _, _, _, _, _ in next_tasks for task in next_tasks
) )
) )
+7 -7
View File
@@ -104,9 +104,9 @@ def map_output_updates(
] ]
if isinstance(output_channels, str): if isinstance(output_channels, str):
if updated := [ if updated := [
(node, value) (task.name, value)
for node, _, _, writes, _, _, _ in output_tasks for task in output_tasks
for chan, value in writes for chan, value in task.writes
if chan == output_channels if chan == output_channels
]: ]:
grouped = defaultdict(list) grouped = defaultdict(list)
@@ -119,11 +119,11 @@ def map_output_updates(
else: else:
if updated := [ if updated := [
( (
node, task.name,
{chan: value for chan, value in writes if chan in output_channels}, {chan: value for chan, value in task.writes if chan in output_channels},
) )
for node, _, _, writes, _, _, _ in output_tasks for task in output_tasks
if any(chan in output_channels for chan, _ in writes) if any(chan in output_channels for chan, _ in task.writes)
]: ]:
grouped = defaultdict(list) grouped = defaultdict(list)
for node, value in updated: for node, value in updated:
+15 -39
View File
@@ -16,6 +16,7 @@ from langchain_core.runnables.utils import ConfigurableFieldSpec
from langgraph.constants import CONFIG_KEY_READ from langgraph.constants import CONFIG_KEY_READ
from langgraph.managed.base import ManagedValueSpec from langgraph.managed.base import ManagedValueSpec
from langgraph.pregel.retry import RetryPolicy
from langgraph.pregel.write import ChannelWrite from langgraph.pregel.write import ChannelWrite
from langgraph.utils import RunnableCallable from langgraph.utils import RunnableCallable
@@ -112,6 +113,8 @@ class PregelNode(RunnableBindingBase):
kwargs: Mapping[str, Any] = Field(default_factory=dict) kwargs: Mapping[str, Any] = Field(default_factory=dict)
retry_policy: Optional[RetryPolicy] = None
def get_writers(self) -> list[Runnable]: def get_writers(self) -> list[Runnable]:
"""Get writers with optimizations applied.""" """Get writers with optimizations applied."""
writers = self.writers.copy() writers = self.writers.copy()
@@ -155,6 +158,7 @@ class PregelNode(RunnableBindingBase):
bound: Optional[Runnable[Any, Any]] = None, bound: Optional[Runnable[Any, Any]] = None,
kwargs: Optional[Mapping[str, Any]] = None, kwargs: Optional[Mapping[str, Any]] = None,
config: Optional[RunnableConfig] = None, config: Optional[RunnableConfig] = None,
retry_policy: Optional[RetryPolicy] = None,
**other_kwargs: Any, **other_kwargs: Any,
) -> None: ) -> None:
super().__init__( super().__init__(
@@ -164,6 +168,7 @@ class PregelNode(RunnableBindingBase):
writers=writers or [], writers=writers or [],
bound=bound or DEFAULT_BOUND, bound=bound or DEFAULT_BOUND,
kwargs=kwargs or {}, kwargs=kwargs or {},
retry_policy=retry_policy,
config=merge_configs( config=merge_configs(
config, {"tags": tags or [], "metadata": metadata or {}} config, {"tags": tags or [], "metadata": metadata or {}}
), ),
@@ -180,17 +185,13 @@ class PregelNode(RunnableBindingBase):
assert isinstance( assert isinstance(
self.channels, dict self.channels, dict
), "all channels must be named when using .join()" ), "all channels must be named when using .join()"
return PregelNode( return self.copy(
channels={ update=dict(
**self.channels, channels={
**{chan: chan for chan in channels}, **self.channels,
}, **{chan: chan for chan in channels},
triggers=self.triggers, }
mapper=self.mapper, ),
writers=self.writers,
bound=self.bound,
kwargs=self.kwargs,
config=self.config,
) )
def __or__( def __or__(
@@ -202,36 +203,11 @@ class PregelNode(RunnableBindingBase):
], ],
) -> PregelNode: ) -> PregelNode:
if ChannelWrite.is_writer(other): if ChannelWrite.is_writer(other):
return PregelNode( return self.copy(update=dict(writers=[*self.writers, other]))
channels=self.channels,
triggers=self.triggers,
mapper=self.mapper,
writers=[*self.writers, other],
bound=self.bound,
kwargs=self.kwargs,
config=self.config,
)
elif self.bound is DEFAULT_BOUND: elif self.bound is DEFAULT_BOUND:
return PregelNode( return self.copy(update=dict(bound=coerce_to_runnable(other)))
channels=self.channels,
triggers=self.triggers,
mapper=self.mapper,
writers=self.writers,
bound=coerce_to_runnable(other),
kwargs=self.kwargs,
config=self.config,
)
else: else:
return PregelNode( return self.copy(update=dict(bound=self.bound | other))
channels=self.channels,
triggers=self.triggers,
mapper=self.mapper,
writers=self.writers,
# delegate to __or__ in self.bound
bound=self.bound | other,
kwargs=self.kwargs,
config=self.config,
)
def pipe( def pipe(
self, self,
+4 -52
View File
@@ -2,68 +2,19 @@ import asyncio
import logging import logging
import random import random
import time import time
from typing import Callable, NamedTuple, Optional, Union from typing import Optional
import httpx from langgraph.pregel.types import PregelExecutableTask, RetryPolicy
import requests
from langgraph.pregel.types import PregelExecutableTask
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def default_retry_on(exc: Exception) -> bool:
if isinstance(exc, ConnectionError):
return True
if isinstance(
exc,
(
ValueError,
TypeError,
ArithmeticError,
ImportError,
LookupError,
NameError,
SyntaxError,
RuntimeError,
ReferenceError,
StopIteration,
StopAsyncIteration,
OSError,
),
):
return False
if isinstance(exc, httpx.HTTPStatusError):
return 500 <= exc.response.status_code < 600
if isinstance(exc, requests.HTTPError):
return 500 <= exc.response.status_code < 600 if exc.response else True
return True
class RetryPolicy(NamedTuple):
"""Configuration for retrying nodes."""
initial_interval: float = 0.5
"""Amount of time that must elapse before the first retry occurs. In seconds."""
backoff_factor: float = 2.0
"""Multiplier by which the interval increases after each retry."""
max_interval: float = 128.0
"""Maximum amount of time that may elapse between retries. In seconds."""
max_attempts: int = 10
"""Maximum number of attempts to make before giving up, including the first."""
jitter: bool = True
"""Whether to add random jitter to the interval between retries."""
retry_on: Union[
tuple[Exception, ...], Callable[[Exception], bool]
] = default_retry_on
"""List of exceptions that should trigger a retry, or a callable that returns True for exceptions that should trigger a retry."""
def run_with_retry( def run_with_retry(
task: PregelExecutableTask, task: PregelExecutableTask,
retry_policy: Optional[RetryPolicy], retry_policy: Optional[RetryPolicy],
) -> None: ) -> None:
"""Run a task with retries.""" """Run a task with retries."""
retry_policy = task.retry_policy or retry_policy
interval = retry_policy.initial_interval if retry_policy else 0 interval = retry_policy.initial_interval if retry_policy else 0
attempts = 0 attempts = 0
while True: while True:
@@ -108,6 +59,7 @@ async def arun_with_retry(
stream: bool = False, stream: bool = False,
) -> None: ) -> None:
"""Run a task asynchronously with retries.""" """Run a task asynchronously with retries."""
retry_policy = task.retry_policy or retry_policy
interval = retry_policy.initial_interval if retry_policy else 0 interval = retry_policy.initial_interval if retry_policy else 0
attempts = 0 attempts = 0
while True: while True:
+52 -1
View File
@@ -1,11 +1,61 @@
from collections import deque from collections import deque
from typing import Any, Literal, NamedTuple, Optional, Union from typing import Any, Callable, Literal, NamedTuple, Optional, Union
from langchain_core.runnables import Runnable, RunnableConfig from langchain_core.runnables import Runnable, RunnableConfig
from langgraph.checkpoint.base import CheckpointMetadata from langgraph.checkpoint.base import CheckpointMetadata
def default_retry_on(exc: Exception) -> bool:
import httpx
import requests
if isinstance(exc, ConnectionError):
return True
if isinstance(
exc,
(
ValueError,
TypeError,
ArithmeticError,
ImportError,
LookupError,
NameError,
SyntaxError,
RuntimeError,
ReferenceError,
StopIteration,
StopAsyncIteration,
OSError,
),
):
return False
if isinstance(exc, httpx.HTTPStatusError):
return 500 <= exc.response.status_code < 600
if isinstance(exc, requests.HTTPError):
return 500 <= exc.response.status_code < 600 if exc.response else True
return True
class RetryPolicy(NamedTuple):
"""Configuration for retrying nodes."""
initial_interval: float = 0.5
"""Amount of time that must elapse before the first retry occurs. In seconds."""
backoff_factor: float = 2.0
"""Multiplier by which the interval increases after each retry."""
max_interval: float = 128.0
"""Maximum amount of time that may elapse between retries. In seconds."""
max_attempts: int = 10
"""Maximum number of attempts to make before giving up, including the first."""
jitter: bool = True
"""Whether to add random jitter to the interval between retries."""
retry_on: Union[
tuple[Exception, ...], Callable[[Exception], bool]
] = default_retry_on
"""List of exceptions that should trigger a retry, or a callable that returns True for exceptions that should trigger a retry."""
class PregelTaskDescription(NamedTuple): class PregelTaskDescription(NamedTuple):
name: str name: str
input: Any input: Any
@@ -18,6 +68,7 @@ class PregelExecutableTask(NamedTuple):
writes: deque[tuple[str, Any]] writes: deque[tuple[str, Any]]
config: RunnableConfig config: RunnableConfig
triggers: list[str] triggers: list[str]
retry_policy: Optional[RetryPolicy]
id: str id: str