use pe_core::error::PeError;
use pe_core::node::{NodeContext, NodeFn, NodeFuture, NodeResult};
use pe_core::state::{State, StateUpdate};
use pe_core::types::{END, START};
use pe_graph::{ExecutionOutcome, GraphConfig, RetryPolicy, StateGraph};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct TestState {
messages: Vec<String>,
counter: u32,
thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct TestUpdate {
messages: Option<Vec<String>>,
counter: Option<u32>,
}
impl StateUpdate for TestUpdate {}
impl State for TestState {
type Update = TestUpdate;
fn apply(&mut self, update: TestUpdate) {
if let Some(msgs) = update.messages {
self.messages.extend(msgs);
}
if let Some(c) = update.counter {
self.counter = c;
}
}
}
impl TestState {
fn new() -> Self {
Self {
messages: Vec::new(),
counter: 0,
thread_id: "test".into(),
}
}
}
struct FailThenSucceedNode {
name: &'static str,
fail_count: u32,
attempts: Arc<AtomicU32>,
}
impl FailThenSucceedNode {
fn new(name: &'static str, fail_count: u32, attempts: Arc<AtomicU32>) -> Self {
Self {
name,
fail_count,
attempts,
}
}
}
impl NodeFn<TestState> for FailThenSucceedNode {
fn call(&self, _state: &TestState, _ctx: &NodeContext) -> NodeFuture<TestUpdate> {
let count = self.attempts.fetch_add(1, Ordering::SeqCst);
let fail_count = self.fail_count;
Box::pin(async move {
if count < fail_count {
NodeResult::Error(PeError::Timeout { seconds: 1.0 })
} else {
NodeResult::Update(TestUpdate {
messages: Some(vec!["recovered".into()]),
counter: None,
})
}
})
}
fn name(&self) -> &str {
self.name
}
}
struct AlwaysFailRetryableNode {
name: &'static str,
attempts: Arc<AtomicU32>,
}
impl AlwaysFailRetryableNode {
fn new(name: &'static str, attempts: Arc<AtomicU32>) -> Self {
Self { name, attempts }
}
}
impl NodeFn<TestState> for AlwaysFailRetryableNode {
fn call(&self, _state: &TestState, _ctx: &NodeContext) -> NodeFuture<TestUpdate> {
self.attempts.fetch_add(1, Ordering::SeqCst);
Box::pin(async { NodeResult::Error(PeError::Timeout { seconds: 1.0 }) })
}
fn name(&self) -> &str {
self.name
}
}
struct NonRetryableFailNode {
name: &'static str,
attempts: Arc<AtomicU32>,
}
impl NonRetryableFailNode {
fn new(name: &'static str, attempts: Arc<AtomicU32>) -> Self {
Self { name, attempts }
}
}
impl NodeFn<TestState> for NonRetryableFailNode {
fn call(&self, _state: &TestState, _ctx: &NodeContext) -> NodeFuture<TestUpdate> {
self.attempts.fetch_add(1, Ordering::SeqCst);
Box::pin(async {
NodeResult::Error(PeError::PermissionDenied {
action: "test".into(),
})
})
}
fn name(&self) -> &str {
self.name
}
}
struct AppendNode {
name: &'static str,
msg: &'static str,
}
impl NodeFn<TestState> for AppendNode {
fn call(&self, _state: &TestState, _ctx: &NodeContext) -> NodeFuture<TestUpdate> {
let msg = self.msg.to_string();
Box::pin(async move {
NodeResult::Update(TestUpdate {
messages: Some(vec![msg]),
counter: None,
})
})
}
fn name(&self) -> &str {
self.name
}
}
#[tokio::test]
async fn test_retry_recovers_after_transient_failure() {
let attempts = Arc::new(AtomicU32::new(0));
let node = FailThenSucceedNode::new("flaky", 1, Arc::clone(&attempts));
let graph = StateGraph::new()
.add_node("flaky", node)
.add_edge(START, "flaky")
.add_edge("flaky", END)
.compile()
.unwrap();
let config = GraphConfig::default().with_retry_policy(RetryPolicy {
max_attempts: 3,
initial_interval: Duration::from_millis(1),
jitter: false,
..Default::default()
});
let outcome = graph.invoke(TestState::new(), config).await.unwrap();
match outcome {
ExecutionOutcome::Completed(state) => {
assert_eq!(state.messages, vec!["recovered"]);
}
other => panic!("Expected Completed, got {:?}", other),
}
assert_eq!(attempts.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_retry_exhausts_all_attempts_then_errors() {
let attempts = Arc::new(AtomicU32::new(0));
let node = AlwaysFailRetryableNode::new("always_fail", Arc::clone(&attempts));
let graph = StateGraph::new()
.add_node("always_fail", node)
.add_edge(START, "always_fail")
.add_edge("always_fail", END)
.compile()
.unwrap();
let config = GraphConfig::default().with_retry_policy(RetryPolicy {
max_attempts: 2,
initial_interval: Duration::from_millis(1),
jitter: false,
..Default::default()
});
let err = graph.invoke(TestState::new(), config).await.unwrap_err();
assert!(matches!(err, PeError::Timeout { .. }));
assert_eq!(attempts.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_non_retryable_error_not_retried() {
let attempts = Arc::new(AtomicU32::new(0));
let node = NonRetryableFailNode::new("perm_fail", Arc::clone(&attempts));
let graph = StateGraph::new()
.add_node("perm_fail", node)
.add_edge(START, "perm_fail")
.add_edge("perm_fail", END)
.compile()
.unwrap();
let config = GraphConfig::default().with_retry_policy(RetryPolicy {
max_attempts: 5,
initial_interval: Duration::from_millis(1),
jitter: false,
..Default::default()
});
let err = graph.invoke(TestState::new(), config).await.unwrap_err();
assert!(matches!(err, PeError::PermissionDenied { .. }));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_no_retry_policy_errors_immediately() {
let attempts = Arc::new(AtomicU32::new(0));
let node = AlwaysFailRetryableNode::new("fail_no_policy", Arc::clone(&attempts));
let graph = StateGraph::new()
.add_node("fail_no_policy", node)
.add_edge(START, "fail_no_policy")
.add_edge("fail_no_policy", END)
.compile()
.unwrap();
let config = GraphConfig::default();
let err = graph.invoke(TestState::new(), config).await.unwrap_err();
assert!(matches!(err, PeError::Timeout { .. }));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_retry_succeeds_on_last_attempt() {
let attempts = Arc::new(AtomicU32::new(0));
let node = FailThenSucceedNode::new("last_chance", 3, Arc::clone(&attempts));
let graph = StateGraph::new()
.add_node("last_chance", node)
.add_edge(START, "last_chance")
.add_edge("last_chance", END)
.compile()
.unwrap();
let config = GraphConfig::default().with_retry_policy(RetryPolicy {
max_attempts: 3,
initial_interval: Duration::from_millis(1),
jitter: false,
..Default::default()
});
let outcome = graph.invoke(TestState::new(), config).await.unwrap();
match outcome {
ExecutionOutcome::Completed(state) => {
assert_eq!(state.messages, vec!["recovered"]);
}
other => panic!("Expected Completed, got {:?}", other),
}
assert_eq!(attempts.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn test_retry_only_retries_failed_node_in_multi_node_graph() {
let flaky_attempts = Arc::new(AtomicU32::new(0));
let graph = StateGraph::new()
.add_node(
"first",
AppendNode {
name: "first",
msg: "step1",
},
)
.add_node(
"flaky",
FailThenSucceedNode::new("flaky", 1, Arc::clone(&flaky_attempts)),
)
.add_edge(START, "first")
.add_edge("first", "flaky")
.add_edge("flaky", END)
.compile()
.unwrap();
let config = GraphConfig::default().with_retry_policy(RetryPolicy {
max_attempts: 3,
initial_interval: Duration::from_millis(1),
jitter: false,
..Default::default()
});
let outcome = graph.invoke(TestState::new(), config).await.unwrap();
match outcome {
ExecutionOutcome::Completed(state) => {
assert_eq!(state.messages, vec!["step1", "recovered"]);
}
other => panic!("Expected Completed, got {:?}", other),
}
assert_eq!(flaky_attempts.load(Ordering::SeqCst), 2);
}