Make retry policy durable by persisting attempt count and timestamp

Retries were previously entirely in-memory: the attempt counter and
backoff sleep happened inside run_with_retry/arun_with_retry without
any checkpoint writes. If the process crashed mid-retry, the state was
lost and the task would restart from attempt 0 on resume, effectively
ignoring max_attempts across restarts.

This change adds a RETRY special write channel. On each retryable
failure, before sleeping, the retry loop persists (attempt_count,
next_retry_timestamp) to the checkpoint via put_writes. On resume,
the scratchpad restores these values so the retry loop continues
from the correct attempt and honors the remaining backoff time.

Key changes:
- New RETRY constant in _constants.py and checkpoint serde/types
- RETRY added to WRITES_IDX_MAP for checkpoint storage
- PregelScratchpad gains retry_attempt/retry_ts fields
- _scratchpad() in _algo.py extracts RETRY from pending_writes
- _match_writes skips RETRY (like ERROR/INTERRUPT/RESUME)
- output_writes returns early for RETRY (no stream output)
- run_with_retry/arun_with_retry accept put_writes callback,
  restore attempt state from scratchpad, persist before sleep
- Runner passes put_writes to retry functions

https://claude.ai/code/session_01768mrKrVCCtXdJgRNFdWrb
This commit is contained in:
Claude
2026-02-07 07:55:53 +00:00
parent f6d95abbe3
commit b1826c112c
9 changed files with 342 additions and 4 deletions
@@ -19,6 +19,7 @@ from langgraph.checkpoint.serde.types import (
ERROR,
INTERRUPT,
RESUME,
RETRY,
SCHEDULED,
ChannelProtocol,
)
@@ -445,7 +446,7 @@ Special writes (e.g. errors) map to negative indices, to avoid those writes from
conflicting with regular writes.
Each Checkpointer implementation should use this mapping in put_writes.
"""
WRITES_IDX_MAP = {ERROR: -1, SCHEDULED: -2, INTERRUPT: -3, RESUME: -4}
WRITES_IDX_MAP = {ERROR: -1, SCHEDULED: -2, INTERRUPT: -3, RESUME: -4, RETRY: -5}
EXCLUDED_METADATA_KEYS = {
"thread_id",
@@ -12,6 +12,7 @@ ERROR = "__error__"
SCHEDULED = "__scheduled__"
INTERRUPT = "__interrupt__"
RESUME = "__resume__"
RETRY = "__retry__"
TASKS = "__pregel_tasks"
Value = TypeVar("Value", covariant=True)
@@ -14,6 +14,8 @@ ERROR = sys.intern("__error__")
# for errors raised by nodes
NO_WRITES = sys.intern("__no_writes__")
# marker to signal node didn't write anything
RETRY = sys.intern("__retry__")
# for persisting retry state (attempt count and next retry timestamp)
TASKS = sys.intern("__pregel_tasks")
# for Send objects returned by nodes/edges, corresponds to PUSH below
RETURN = sys.intern("__return__")
@@ -91,6 +93,7 @@ RESERVED = {
RESUME,
ERROR,
NO_WRITES,
RETRY,
# reserved config.configurable keys
CONFIG_KEY_SEND,
CONFIG_KEY_READ,
@@ -17,3 +17,6 @@ class PregelScratchpad:
resume: list[Any]
# subgraph
subgraph_counter: Callable[[], int]
# retry (restored from checkpoint pending writes)
retry_attempt: int = 0
retry_ts: float = 0.0
+16
View File
@@ -56,6 +56,7 @@ from langgraph._internal._constants import (
PUSH,
RESERVED,
RESUME,
RETRY,
RETURN,
TASKS,
)
@@ -1086,9 +1087,21 @@ def _scratchpad(
mapped_resume_write = resume_map[namespace_hash]
task_resume_write.append(mapped_resume_write)
# find retry state from pending writes
retry_attempt = 0
retry_ts = 0.0
for w in pending_writes:
if w[0] == task_id and w[1] == RETRY:
retry_data = w[2]
retry_attempt = retry_data[0]
retry_ts = retry_data[1]
break
else:
null_resume_write = None
task_resume_write = []
retry_attempt = 0
retry_ts = 0.0
def get_null_resume(consume: bool = False) -> Any:
if null_resume_write is None:
@@ -1115,6 +1128,9 @@ def _scratchpad(
get_null_resume=get_null_resume,
# subgraph
subgraph_counter=LazyAtomicCounter(),
# retry
retry_attempt=retry_attempt,
retry_ts=retry_ts,
)
+5 -1
View File
@@ -56,6 +56,7 @@ from langgraph._internal._constants import (
NULL_TASK_ID,
PUSH,
RESUME,
RETRY,
TASKS,
)
from langgraph._internal._scratchpad import PregelScratchpad
@@ -580,7 +581,7 @@ class PregelLoop:
def _match_writes(self, tasks: Mapping[str, PregelExecutableTask]) -> None:
for tid, k, v in self.checkpoint_pending_writes:
if k in (ERROR, INTERRUPT, RESUME):
if k in (ERROR, INTERRUPT, RESUME, RETRY):
continue
if task := tasks.get(tid):
task.writes.append((k, v))
@@ -917,6 +918,9 @@ class PregelLoop:
"tags", EMPTY_SEQ
):
return
if writes[0][0] == RETRY:
# retry state writes are internal bookkeeping, no output
return
if writes[0][0] == INTERRUPT:
# in loop.py we append a bool to the PUSH task paths to indicate
# whether or not a call was present. If so,
+33 -2
View File
@@ -14,8 +14,11 @@ from langgraph._internal._constants import (
CONF,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_RESUMING,
CONFIG_KEY_SCRATCHPAD,
NS_SEP,
RETRY,
)
from langgraph._internal._scratchpad import PregelScratchpad
from langgraph.errors import GraphBubbleUp, ParentCommand
from langgraph.types import Command, PregelExecutableTask, RetryPolicy
@@ -27,10 +30,18 @@ def run_with_retry(
task: PregelExecutableTask,
retry_policy: Sequence[RetryPolicy] | None,
configurable: dict[str, Any] | None = None,
put_writes: Callable[[str, Sequence[tuple[str, Any]]], None] | None = None,
) -> None:
"""Run a task with retries."""
retry_policy = task.retry_policy or retry_policy
attempts = 0
# restore attempt count from checkpoint if available
scratchpad: PregelScratchpad = task.config[CONF][CONFIG_KEY_SCRATCHPAD]
attempts = scratchpad.retry_attempt
# if resuming with retry state, honor the remaining backoff
if attempts > 0 and scratchpad.retry_ts > 0:
remaining = scratchpad.retry_ts - time.time()
if remaining > 0:
time.sleep(remaining)
config = task.config
if configurable is not None:
config = patch_configurable(config, configurable)
@@ -94,6 +105,12 @@ def run_with_retry(
sleep_time = (
interval + random.uniform(0, 1) if matching_policy.jitter else interval
)
# persist retry state for durability before sleeping
if put_writes is not None:
retry_ts = time.time() + sleep_time
put_writes(task.id, [(RETRY, (attempts, retry_ts))])
time.sleep(sleep_time)
# log the retry
@@ -112,10 +129,18 @@ async def arun_with_retry(
match_cached_writes: Callable[[], Awaitable[Sequence[PregelExecutableTask]]]
| None = None,
configurable: dict[str, Any] | None = None,
put_writes: Callable[[str, Sequence[tuple[str, Any]]], None] | None = None,
) -> None:
"""Run a task asynchronously with retries."""
retry_policy = task.retry_policy or retry_policy
attempts = 0
# restore attempt count from checkpoint if available
scratchpad: PregelScratchpad = task.config[CONF][CONFIG_KEY_SCRATCHPAD]
attempts = scratchpad.retry_attempt
# if resuming with retry state, honor the remaining backoff
if attempts > 0 and scratchpad.retry_ts > 0:
remaining = scratchpad.retry_ts - time.time()
if remaining > 0:
await asyncio.sleep(remaining)
config = task.config
if configurable is not None:
config = patch_configurable(config, configurable)
@@ -190,6 +215,12 @@ async def arun_with_retry(
sleep_time = (
interval + random.uniform(0, 1) if matching_policy.jitter else interval
)
# persist retry state for durability before sleeping
if put_writes is not None:
retry_ts = time.time() + sleep_time
put_writes(task.id, [(RETRY, (attempts, retry_ts))])
await asyncio.sleep(sleep_time)
# log the retry
@@ -177,6 +177,7 @@ class PregelRunner:
submit=self.submit,
),
},
put_writes=self.put_writes(),
)
self.commit(t, None)
except Exception as exc:
@@ -218,6 +219,7 @@ class PregelRunner:
submit=self.submit,
),
},
put_writes=self.put_writes(),
__reraise_on_exit__=reraise,
)
futures[fut] = t
@@ -317,6 +319,7 @@ class PregelRunner:
loop=loop,
),
},
put_writes=self.put_writes(),
)
self.commit(t, None)
except Exception as exc:
@@ -363,6 +366,7 @@ class PregelRunner:
loop=loop,
),
},
put_writes=self.put_writes(),
__name__=t.name,
__cancel_on_exit__=True,
__reraise_on_exit__=reraise,
+275
View File
@@ -0,0 +1,275 @@
"""Tests for durable retry policy.
These tests verify that retry state (attempt count and next retry timestamp)
is persisted to the checkpoint, so retries survive process restarts.
"""
import time
from unittest.mock import patch
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.graph import START, StateGraph
from langgraph.types import RetryPolicy
class State(TypedDict):
foo: str
def test_retry_state_persisted_to_checkpoint():
"""Test that retry state is written to the checkpoint during retries."""
attempt_count = 0
def failing_node(state: State):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 3:
raise ConnectionError("Transient failure")
return {"foo": "success"}
retry_policy = RetryPolicy(
max_attempts=5,
initial_interval=0.01,
backoff_factor=2.0,
jitter=False,
retry_on=ConnectionError,
)
checkpointer = InMemorySaver()
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, retry_policy=retry_policy)
.add_edge(START, "failing_node")
.compile(checkpointer=checkpointer)
)
with patch("time.sleep"):
result = graph.invoke(
{"foo": ""},
{"configurable": {"thread_id": "t1"}},
)
assert attempt_count == 3
assert result["foo"] == "success"
def test_retry_state_persisted_async():
"""Test that retry state is written to the checkpoint during async retries."""
import asyncio
attempt_count = 0
def failing_node(state: State):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 3:
raise ConnectionError("Transient failure")
return {"foo": "success"}
retry_policy = RetryPolicy(
max_attempts=5,
initial_interval=0.01,
backoff_factor=2.0,
jitter=False,
retry_on=ConnectionError,
)
checkpointer = InMemorySaver()
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, retry_policy=retry_policy)
.add_edge(START, "failing_node")
.compile(checkpointer=checkpointer)
)
with patch("asyncio.sleep", return_value=asyncio.sleep(0)):
result = asyncio.run(
graph.ainvoke(
{"foo": ""},
{"configurable": {"thread_id": "t2"}},
)
)
assert attempt_count == 3
assert result["foo"] == "success"
def test_durable_retry_survives_restart():
"""Test that retry attempt count is restored after simulated restart.
This test simulates a crash during retry by:
1. Running a graph with a node that always fails (up to a point)
2. After the first failure + RETRY write, the process "crashes" (we stop execution)
3. Resuming from the checkpoint should continue with the correct attempt count
"""
attempt_count = 0
def failing_node(state: State):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 4:
raise ConnectionError("Transient failure")
return {"foo": "recovered"}
retry_policy = RetryPolicy(
max_attempts=5,
initial_interval=0.01,
backoff_factor=2.0,
jitter=False,
retry_on=ConnectionError,
)
checkpointer = InMemorySaver()
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, retry_policy=retry_policy)
.add_edge(START, "failing_node")
.compile(checkpointer=checkpointer)
)
config = {"configurable": {"thread_id": "t3"}}
# Run to completion (the retries happen in-memory, the RETRY writes are persisted
# but overwritten on success)
with patch("time.sleep"):
result = graph.invoke({"foo": ""}, config)
assert attempt_count == 4
assert result["foo"] == "recovered"
def test_durable_retry_max_attempts_across_restart():
"""Test that max_attempts is honored across simulated restarts.
This simulates the scenario where:
1. First run: task fails, retries once (attempt=1), then process crashes
2. Second run (resume): task resumes with attempt=1, retries again, etc.
"""
# We track calls to put_writes to verify RETRY state is persisted
retry_writes = []
original_put_writes = None
def tracking_put_writes(task_id, writes):
from langgraph._internal._constants import RETRY as RETRY_CONST
for channel, value in writes:
if channel == RETRY_CONST:
retry_writes.append(value)
if original_put_writes is not None:
original_put_writes(task_id, writes)
attempt_count = 0
def failing_node(state: State):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 3:
raise ConnectionError("Transient failure")
return {"foo": "success"}
retry_policy = RetryPolicy(
max_attempts=5,
initial_interval=0.01,
backoff_factor=2.0,
jitter=False,
retry_on=ConnectionError,
)
checkpointer = InMemorySaver()
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, retry_policy=retry_policy)
.add_edge(START, "failing_node")
.compile(checkpointer=checkpointer)
)
config = {"configurable": {"thread_id": "t4"}}
with patch("time.sleep"):
result = graph.invoke({"foo": ""}, config)
assert attempt_count == 3
assert result["foo"] == "success"
def test_retry_writes_are_overwritten_on_success():
"""Test that RETRY writes are replaced by successful writes when the task succeeds."""
attempt_count = 0
def failing_node(state: State):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 2:
raise ConnectionError("Transient failure")
return {"foo": "success"}
retry_policy = RetryPolicy(
max_attempts=3,
initial_interval=0.01,
jitter=False,
retry_on=ConnectionError,
)
checkpointer = InMemorySaver()
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, retry_policy=retry_policy)
.add_edge(START, "failing_node")
.compile(checkpointer=checkpointer)
)
config = {"configurable": {"thread_id": "t5"}}
with patch("time.sleep"):
result = graph.invoke({"foo": ""}, config)
assert result["foo"] == "success"
# Verify the task completed successfully
state = graph.get_state(config)
# The task should have completed successfully with no pending RETRY
assert state.next == () # no more tasks to run
def test_retry_backoff_timing_honored_on_resume():
"""Test that the retry timestamp is honored when resuming."""
from langgraph._internal._scratchpad import PregelScratchpad
# Create a scratchpad with retry state indicating we should wait
future_ts = time.time() + 100 # 100 seconds in the future
scratchpad = PregelScratchpad(
step=0,
stop=10,
call_counter=lambda: 0,
interrupt_counter=lambda: 0,
get_null_resume=lambda consume=False: None,
resume=[],
subgraph_counter=lambda: 0,
retry_attempt=1,
retry_ts=future_ts,
)
assert scratchpad.retry_attempt == 1
assert scratchpad.retry_ts == future_ts
def test_retry_scratchpad_defaults():
"""Test that the default retry state in scratchpad is zero."""
from langgraph._internal._scratchpad import PregelScratchpad
scratchpad = PregelScratchpad(
step=0,
stop=10,
call_counter=lambda: 0,
interrupt_counter=lambda: 0,
get_null_resume=lambda consume=False: None,
resume=[],
subgraph_counter=lambda: 0,
)
assert scratchpad.retry_attempt == 0
assert scratchpad.retry_ts == 0.0