use adk_graph::edge::{END, START};
use adk_graph::error::GraphError;
use adk_graph::graph::StateGraph;
use adk_graph::node::{ExecutionConfig, NodeOutput};
use adk_graph::retry::{RetryOn, RetryPolicy};
use adk_graph::state::State;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
fn flaky(fail_times: usize, calls: Arc<AtomicUsize>) -> impl Fn(usize) -> bool + Clone {
let _ = calls;
move |call| call < fail_times
}
#[tokio::test]
async fn a_transient_failure_is_retried() {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let should_fail = flaky(2, Arc::clone(&calls));
let graph = StateGraph::with_channels(&["value"])
.add_node_fn("flaky", move |_ctx| {
let counter = Arc::clone(&counter);
let should_fail = should_fail.clone();
async move {
let call = counter.fetch_add(1, Ordering::SeqCst);
if should_fail(call) {
return Err(GraphError::NodeExecutionFailed {
node: "flaky".to_string(),
message: format!("attempt {} failed", call + 1),
});
}
Ok(NodeOutput::new().with_update("value", json!("ok")))
}
})
.add_edge(START, "flaky")
.add_edge("flaky", END)
.compile()
.unwrap()
.with_node_retry(
"flaky",
RetryPolicy::new(4).with_initial_delay(Duration::from_millis(5)).with_jitter(0.0),
);
let state = graph.invoke(State::new(), ExecutionConfig::new("retry-1")).await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 3, "two failures then a success");
assert_eq!(state.get("value").and_then(|v| v.as_str()), Some("ok"));
}
#[tokio::test]
async fn without_a_policy_the_first_failure_ends_the_run() {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let graph = StateGraph::with_channels(&["value"])
.add_node_fn("always_fails", move |_ctx| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
Err(GraphError::NodeExecutionFailed {
node: "always_fails".to_string(),
message: "boom".to_string(),
})
}
})
.add_edge(START, "always_fails")
.add_edge("always_fails", END)
.compile()
.unwrap();
let outcome = graph.invoke(State::new(), ExecutionConfig::new("retry-2")).await;
assert!(outcome.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 1, "one attempt when no policy is configured");
}
#[tokio::test]
async fn an_exhausted_budget_fails_the_run() {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let graph = StateGraph::with_channels(&["value"])
.add_node_fn("always_fails", move |_ctx| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
Err(GraphError::NodeExecutionFailed {
node: "always_fails".to_string(),
message: "boom".to_string(),
})
}
})
.add_edge(START, "always_fails")
.add_edge("always_fails", END)
.compile()
.unwrap()
.with_node_retry(
"always_fails",
RetryPolicy::new(3).with_initial_delay(Duration::from_millis(1)).with_jitter(0.0),
);
let outcome = graph.invoke(State::new(), ExecutionConfig::new("retry-3")).await;
assert!(outcome.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 3, "exactly the configured number of attempts");
}
#[tokio::test]
async fn delays_grow_between_attempts() {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let started = Instant::now();
let graph = StateGraph::with_channels(&["value"])
.add_node_fn("always_fails", move |_ctx| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
Err(GraphError::NodeExecutionFailed {
node: "always_fails".to_string(),
message: "boom".to_string(),
})
}
})
.add_edge(START, "always_fails")
.add_edge("always_fails", END)
.compile()
.unwrap()
.with_node_retry(
"always_fails",
RetryPolicy::new(4)
.with_initial_delay(Duration::from_millis(30))
.with_backoff_factor(2.0)
.with_jitter(0.0),
);
let _ = graph.invoke(State::new(), ExecutionConfig::new("retry-4")).await;
let elapsed = started.elapsed();
assert!(
elapsed >= Duration::from_millis(200),
"elapsed {elapsed:?} is too short for 30ms + 60ms + 120ms of backoff"
);
assert_eq!(calls.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn a_timeout_policy_does_not_retry_other_errors() {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let graph = StateGraph::with_channels(&["value"])
.add_node_fn("always_fails", move |_ctx| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
Err(GraphError::NodeExecutionFailed {
node: "always_fails".to_string(),
message: "not a timeout".to_string(),
})
}
})
.add_edge(START, "always_fails")
.add_edge("always_fails", END)
.compile()
.unwrap()
.with_node_retry(
"always_fails",
RetryPolicy::new(5)
.with_initial_delay(Duration::from_millis(1))
.with_retry_on(RetryOn::Timeout),
);
let _ = graph.invoke(State::new(), ExecutionConfig::new("retry-5")).await;
assert_eq!(calls.load(Ordering::SeqCst), 1, "a non-timeout error must not be retried");
}
#[tokio::test]
async fn an_interrupt_is_not_retried() {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let graph = StateGraph::with_channels(&["value"])
.add_node_fn("gate", move |_ctx| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
Ok(NodeOutput::interrupt("approve?"))
}
})
.add_edge(START, "gate")
.add_edge("gate", END)
.compile()
.unwrap()
.with_checkpointer(adk_graph::checkpoint::MemoryCheckpointer::new())
.with_node_retry(
"gate",
RetryPolicy::new(5)
.with_initial_delay(Duration::from_millis(1))
.with_retry_on(RetryOn::Custom(Arc::new(|_| true))),
);
let outcome = graph.invoke(State::new(), ExecutionConfig::new("retry-6")).await;
assert!(matches!(outcome, Err(GraphError::Interrupted(_))));
assert_eq!(calls.load(Ordering::SeqCst), 1, "the gate must be asked once, not retried");
}
#[tokio::test]
async fn a_retry_budget_survives_a_resume() {
use adk_graph::checkpoint::MemoryCheckpointer;
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let graph = StateGraph::with_channels(&["value"])
.add_node_fn("gate", |_ctx| async move {
Ok(NodeOutput::new().with_update("value", json!("gated")))
})
.add_node_fn("always_fails", move |_ctx| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
Err(GraphError::NodeExecutionFailed {
node: "always_fails".to_string(),
message: "boom".to_string(),
})
}
})
.add_edge(START, "gate")
.add_edge("gate", "always_fails")
.add_edge("always_fails", END)
.compile()
.unwrap()
.with_checkpointer(MemoryCheckpointer::new())
.with_node_retry(
"always_fails",
RetryPolicy::new(3).with_initial_delay(Duration::from_millis(1)).with_jitter(0.0),
);
let first = graph.invoke(State::new(), ExecutionConfig::new("budget")).await;
assert!(first.is_err());
let after_first = calls.load(Ordering::SeqCst);
assert_eq!(after_first, 3, "the first run spends the whole budget");
let second = graph.invoke(State::new(), ExecutionConfig::new("budget")).await;
assert!(second.is_err());
let after_second = calls.load(Ordering::SeqCst) - after_first;
assert_eq!(
after_second, 1,
"a resumed run must attempt once more, not restart the budget: got {after_second}"
);
}