use crate::provider::ProviderError;
use std::time::Duration;
use tracing::warn;
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: usize,
pub initial_delay_ms: u64,
pub backoff_multiplier: f64,
pub max_delay_ms: u64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_delay_ms: 1000,
backoff_multiplier: 2.0,
max_delay_ms: 30_000,
}
}
}
impl RetryConfig {
pub fn none() -> Self {
Self {
max_retries: 0,
..Default::default()
}
}
pub fn delay_for_attempt(&self, attempt: usize) -> Duration {
let base_ms = self.initial_delay_ms as f64
* self
.backoff_multiplier
.powi(attempt.saturating_sub(1) as i32);
let capped_ms = base_ms.min(self.max_delay_ms as f64);
let jitter = 0.8 + rand::random::<f64>() * 0.4;
Duration::from_millis((capped_ms * jitter) as u64)
}
}
impl ProviderError {
pub fn is_retryable(&self) -> bool {
matches!(self, Self::RateLimited { .. } | Self::Network(_))
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited {
retry_after_ms: Some(ms),
} => Some(Duration::from_millis(*ms)),
_ => None,
}
}
}
pub(crate) fn log_retry(attempt: usize, max: usize, delay: &Duration, error: &ProviderError) {
warn!(
"Provider error (attempt {}/{}), retrying in {:.1}s: {}",
attempt,
max,
delay.as_secs_f64(),
error
);
}
#[derive(Debug, Default)]
pub struct RetrySafeEvents {
held: Held,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Default)]
enum Held {
#[default]
Nothing,
Open {
start: crate::AgentEvent,
updates: Vec<crate::AgentEvent>,
},
Failed {
start: Option<crate::AgentEvent>,
end: crate::AgentEvent,
},
}
impl RetrySafeEvents {
pub fn new() -> Self {
Self::default()
}
#[must_use = "the returned events are the filter's output; dropping them loses them"]
pub fn push(&mut self, event: crate::AgentEvent) -> Vec<crate::AgentEvent> {
use crate::AgentEvent;
let mut out = Vec::new();
match std::mem::take(&mut self.held) {
Held::Nothing => {}
Held::Failed { start, end } => {
if matches!(event, AgentEvent::ProviderRetry { .. }) {
out.push(event);
return out;
}
out.extend(start);
out.push(end);
}
Held::Open { start, updates } => match event {
AgentEvent::MessageUpdate { .. } => {
let mut updates = updates;
updates.push(event);
self.held = Held::Open { start, updates };
return out;
}
AgentEvent::MessageEnd { ref message } if is_assistant(message) => {
if ended_without_answer(message) {
self.held = Held::Failed {
start: Some(start),
end: event,
};
} else {
out.push(start);
out.extend(updates);
out.push(event);
}
return out;
}
AgentEvent::MessageStart { ref message } if is_assistant(message) => {
tracing::warn!("retry-safe events: an attempt started before the previous one ended; dropping the unfinished one");
self.held = Held::Open {
start: event,
updates: Vec::new(),
};
return out;
}
AgentEvent::ProviderRetry { .. } => {
tracing::warn!("retry-safe events: a retry arrived inside an open attempt; dropping the attempt");
out.push(event);
return out;
}
other => {
self.held = Held::Open { start, updates };
out.push(other);
return out;
}
},
}
match event {
AgentEvent::MessageStart { ref message } if is_assistant(message) => {
self.held = Held::Open {
start: event,
updates: Vec::new(),
};
}
AgentEvent::MessageEnd { ref message }
if is_assistant(message) && ended_without_answer(message) =>
{
self.held = Held::Failed {
start: None,
end: event,
};
}
other => out.push(other),
}
out
}
#[must_use = "the returned events are the filter's output; dropping them loses them"]
pub fn finish(&mut self) -> Vec<crate::AgentEvent> {
match std::mem::take(&mut self.held) {
Held::Nothing => Vec::new(),
Held::Open { updates, .. } => {
tracing::warn!(
updates = updates.len(),
"retry-safe events: the stream ended inside an attempt; dropping it"
);
Vec::new()
}
Held::Failed { start, end } => start.into_iter().chain([end]).collect(),
}
}
}
fn is_assistant(message: &crate::AgentMessage) -> bool {
matches!(
message,
crate::AgentMessage::Llm(crate::Message::Assistant { .. })
)
}
fn ended_without_answer(message: &crate::AgentMessage) -> bool {
matches!(
message,
crate::AgentMessage::Llm(crate::Message::Assistant {
stop_reason: crate::StopReason::Error | crate::StopReason::Aborted,
..
})
)
}
#[must_use = "the returned receiver carries the run's events"]
pub fn retry_safe_events(
mut rx: tokio::sync::mpsc::UnboundedReceiver<crate::AgentEvent>,
) -> tokio::sync::mpsc::UnboundedReceiver<crate::AgentEvent> {
let (tx, out) = tokio::sync::mpsc::unbounded_channel();
crate::rt::spawn(async move {
let mut filter = RetrySafeEvents::new();
while let Some(event) = rx.recv().await {
for ready in filter.push(event) {
if tx.send(ready).is_err() {
return;
}
}
}
for ready in filter.finish() {
let _ = tx.send(ready);
}
});
out
}
#[cfg(test)]
mod retry_safe {
use super::RetrySafeEvents;
use crate::{AgentEvent, AgentMessage, Message, StopReason, StreamDelta, Usage};
fn assistant(model: &str, stop: StopReason) -> AgentMessage {
AgentMessage::Llm(
Message::assistant(vec![], stop, model, "p", Usage::default()).with_timestamp(0),
)
}
fn start(model: &str) -> AgentEvent {
AgentEvent::MessageStart {
message: assistant(model, StopReason::Stop),
}
}
fn update(model: &str, delta: StreamDelta) -> AgentEvent {
AgentEvent::MessageUpdate {
message: assistant(model, StopReason::Stop),
delta,
}
}
fn text(model: &str, t: &str) -> AgentEvent {
update(model, StreamDelta::Text { delta: t.into() })
}
fn end(model: &str, stop: StopReason) -> AgentEvent {
AgentEvent::MessageEnd {
message: assistant(model, stop),
}
}
fn retry(attempt: usize) -> AgentEvent {
AgentEvent::ProviderRetry {
attempt,
max_attempts: 3,
error: "cut".into(),
delay_ms: 1,
}
}
fn progress() -> AgentEvent {
AgentEvent::ProgressMessage {
tool_call_id: "t".into(),
tool_name: "n".into(),
text: "working".into(),
}
}
fn feed(events: Vec<AgentEvent>) -> Vec<AgentEvent> {
let mut filter = RetrySafeEvents::new();
let mut out: Vec<AgentEvent> = events.into_iter().flat_map(|e| filter.push(e)).collect();
out.extend(filter.finish());
out
}
#[test]
fn a_successful_attempt_is_released_whole_at_its_end() {
let mut filter = RetrySafeEvents::new();
assert!(filter.push(start("a")).is_empty());
assert!(filter.push(text("a", "x")).is_empty());
assert_eq!(
filter.push(end("a", StopReason::Stop)),
vec![start("a"), text("a", "x"), end("a", StopReason::Stop)]
);
}
#[test]
fn a_retried_attempt_leaves_only_its_marker() {
let out = feed(vec![
start("a"),
text("a", "PARTIAL_"),
end("a", StopReason::Error),
retry(1),
start("b"),
text("b", "PONG"),
end("b", StopReason::Stop),
]);
assert_eq!(
out,
vec![
retry(1),
start("b"),
text("b", "PONG"),
end("b", StopReason::Stop)
]
);
}
#[test]
fn two_retries_in_a_row_leave_only_their_markers() {
let out = feed(vec![
start("a"),
text("a", "A"),
end("a", StopReason::Error),
retry(1),
start("b"),
text("b", "B"),
end("b", StopReason::Error),
retry(2),
start("c"),
text("c", "C"),
end("c", StopReason::Stop),
]);
assert_eq!(
out,
vec![
retry(1),
retry(2),
start("c"),
text("c", "C"),
end("c", StopReason::Stop)
]
);
}
#[test]
fn every_kind_of_delta_is_held_and_dropped_with_its_attempt() {
let out = feed(vec![
start("a"),
update("a", StreamDelta::Thinking { delta: "t".into() }),
update("a", StreamDelta::ToolCallDelta { delta: "{".into() }),
text("a", "x"),
end("a", StopReason::Error),
retry(1),
]);
assert_eq!(out, vec![retry(1)]);
}
#[test]
fn a_final_failure_is_released_without_its_text() {
let out = feed(vec![
start("a"),
text("a", "x"),
end("a", StopReason::Error),
AgentEvent::TurnStart,
]);
assert_eq!(
out,
vec![
start("a"),
end("a", StopReason::Error),
AgentEvent::TurnStart
]
);
let out = feed(vec![
start("a"),
text("a", "x"),
end("a", StopReason::Aborted),
]);
assert_eq!(out, vec![start("a"), end("a", StopReason::Aborted)]);
}
#[test]
fn an_error_end_without_a_start_is_held_like_any_failure() {
assert_eq!(
feed(vec![end("a", StopReason::Error), retry(1)]),
vec![retry(1)]
);
assert_eq!(
feed(vec![end("a", StopReason::Error)]),
vec![end("a", StopReason::Error)]
);
}
#[test]
fn an_attempt_the_stream_ends_inside_is_dropped() {
assert!(feed(vec![start("a"), text("a", "x")]).is_empty());
}
#[test]
fn an_unclosed_attempt_is_dropped_when_another_starts() {
let out = feed(vec![
start("a"),
text("a", "x"),
start("b"),
end("b", StopReason::Stop),
]);
assert_eq!(out, vec![start("b"), end("b", StopReason::Stop)]);
}
#[test]
fn an_unclosed_attempt_is_dropped_when_a_retry_follows() {
let out = feed(vec![
start("a"),
text("a", "x"),
retry(1),
start("b"),
end("b", StopReason::Stop),
]);
assert_eq!(out, vec![retry(1), start("b"), end("b", StopReason::Stop)]);
}
#[test]
fn other_events_overtake_a_held_attempt() {
let out = feed(vec![
start("a"),
text("a", "x"),
progress(),
end("a", StopReason::Stop),
]);
assert_eq!(
out,
vec![
progress(),
start("a"),
text("a", "x"),
end("a", StopReason::Stop)
]
);
}
#[test]
fn updates_outside_an_attempt_pass_through() {
assert_eq!(feed(vec![text("a", "x")]), vec![text("a", "x")]);
}
#[test]
fn finish_resets_the_filter_for_another_stream() {
let mut filter = RetrySafeEvents::new();
let _ = filter.push(start("a"));
let _ = filter.push(end("a", StopReason::Error));
assert_eq!(
filter.finish(),
vec![start("a"), end("a", StopReason::Error)]
);
assert!(filter.finish().is_empty());
assert_eq!(
filter.push(AgentEvent::TurnStart),
vec![AgentEvent::TurnStart]
);
}
}
#[cfg(test)]
mod attempt_indexing {
use super::RetryConfig;
#[test]
fn a_zero_attempt_does_not_panic() {
let cfg = RetryConfig {
initial_delay_ms: 1000,
backoff_multiplier: 2.0,
max_delay_ms: 60_000,
..RetryConfig::default()
};
let zero = cfg.delay_for_attempt(0).as_millis();
assert!(
(800..=1200).contains(&zero),
"attempt 0 must degrade to the base delay, got {zero}ms"
);
}
#[test]
fn backoff_grows_with_the_attempt_number() {
let cfg = RetryConfig {
initial_delay_ms: 1000,
backoff_multiplier: 2.0,
max_delay_ms: 60_000,
..RetryConfig::default()
};
let first = cfg.delay_for_attempt(1).as_millis();
let third = cfg.delay_for_attempt(3).as_millis();
assert!(
third > first * 2,
"attempt 3 must back off well beyond attempt 1, got {first}ms then {third}ms"
);
}
}