Skip to main content

open_agent/
retry.rs

1//! Retry utilities with exponential backoff
2//!
3//! This module provides utilities for retrying operations with configurable
4//! backoff strategies. Useful for handling transient failures when communicating
5//! with LLM servers.
6//!
7//! # Examples
8//!
9//! ```rust,no_run
10//! use open_agent::retry::{retry_with_backoff, RetryConfig};
11//! use std::time::Duration;
12//!
13//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
14//! let config = RetryConfig::default()
15//!     .with_max_attempts(3)
16//!     .with_initial_delay(Duration::from_secs(1));
17//!
18//! let result = retry_with_backoff(config, || async {
19//!     // Your async operation here
20//!     Ok::<_, open_agent::Error>(42)
21//! }).await?;
22//! # Ok(())
23//! # }
24//! ```
25
26use crate::{Error, Result};
27use std::future::Future;
28use std::time::Duration;
29use tokio::time::sleep;
30
31/// Configuration for retry behavior
32#[derive(Debug, Clone)]
33pub struct RetryConfig {
34    /// Maximum number of retry attempts
35    pub max_attempts: u32,
36
37    /// Initial delay before first retry
38    pub initial_delay: Duration,
39
40    /// Maximum delay between retries
41    pub max_delay: Duration,
42
43    /// Multiplier for exponential backoff (e.g., 2.0 doubles the delay each time)
44    pub backoff_multiplier: f64,
45
46    /// Add random jitter to prevent thundering herd (0.0 to 1.0)
47    pub jitter_factor: f64,
48}
49
50impl Default for RetryConfig {
51    fn default() -> Self {
52        Self {
53            max_attempts: 3,
54            initial_delay: Duration::from_secs(1),
55            max_delay: Duration::from_secs(60),
56            backoff_multiplier: 2.0,
57            jitter_factor: 0.1,
58        }
59    }
60}
61
62impl RetryConfig {
63    /// Create a new retry configuration
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Set maximum number of attempts
69    pub fn with_max_attempts(mut self, attempts: u32) -> Self {
70        self.max_attempts = attempts;
71        self
72    }
73
74    /// Set initial delay
75    pub fn with_initial_delay(mut self, delay: Duration) -> Self {
76        self.initial_delay = delay;
77        self
78    }
79
80    /// Set maximum delay
81    pub fn with_max_delay(mut self, delay: Duration) -> Self {
82        self.max_delay = delay;
83        self
84    }
85
86    /// Set backoff multiplier
87    pub fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
88        self.backoff_multiplier = multiplier;
89        self
90    }
91
92    /// Set jitter factor (0.0 to 1.0)
93    pub fn with_jitter_factor(mut self, jitter: f64) -> Self {
94        self.jitter_factor = jitter.clamp(0.0, 1.0);
95        self
96    }
97
98    /// Calculate delay for a given attempt with exponential backoff and jitter
99    fn calculate_delay(&self, attempt: u32) -> Duration {
100        let base_delay_ms = self.initial_delay.as_millis() as f64;
101        let exponential_delay = base_delay_ms * self.backoff_multiplier.powi(attempt as i32);
102
103        // Cap at max delay
104        let capped_delay = exponential_delay.min(self.max_delay.as_millis() as f64);
105
106        // Add jitter, centred on the capped delay
107        let jitter_range = capped_delay * self.jitter_factor;
108        let jitter = rand::random::<f64>() * jitter_range;
109        let jittered_delay = capped_delay + jitter - (jitter_range / 2.0);
110
111        // Re-apply the cap. Jitter is added *after* capping, so without this a `max_delay` of
112        // 60s would still produce a 66s sleep at a 0.2 jitter factor — which contradicts what
113        // `with_max_delay` promises.
114        let final_delay = jittered_delay.clamp(0.0, self.max_delay.as_millis() as f64);
115
116        Duration::from_millis(final_delay as u64)
117    }
118}
119
120/// Retry an async operation with exponential backoff
121///
122/// # Arguments
123///
124/// * `config` - Retry configuration
125/// * `operation` - Async function to retry
126///
127/// # Returns
128///
129/// The result of the operation if successful, or the last error if all retries failed
130///
131/// # Examples
132///
133/// ```rust,no_run
134/// use open_agent::retry::{retry_with_backoff, RetryConfig};
135/// use open_agent::{Client, AgentOptions};
136///
137/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
138/// let config = RetryConfig::default().with_max_attempts(3);
139/// let options = AgentOptions::builder()
140///     .model("qwen3:8b")
141///     .base_url("http://localhost:11434/v1")
142///     .build()?;
143///
144/// let result = retry_with_backoff(config, || async {
145///     let mut client = Client::new(options.clone())?;
146///     client.send("Hello").await?;
147///     Ok::<_, open_agent::Error>(())
148/// }).await?;
149/// # Ok(())
150/// # }
151/// ```
152pub async fn retry_with_backoff<F, Fut, T>(config: RetryConfig, mut operation: F) -> Result<T>
153where
154    F: FnMut() -> Fut,
155    Fut: Future<Output = Result<T>>,
156{
157    let mut last_error = None;
158
159    for attempt in 0..config.max_attempts {
160        match operation().await {
161            Ok(result) => return Ok(result),
162            Err(err) => {
163                last_error = Some(err);
164
165                // Don't sleep after the last attempt
166                if attempt < config.max_attempts - 1 {
167                    let delay = config.calculate_delay(attempt);
168                    sleep(delay).await;
169                }
170            }
171        }
172    }
173
174    Err(last_error.unwrap_or_else(|| Error::other("Retry failed with no error")))
175}
176
177/// HTTP status codes that indicate a transient failure worth retrying.
178///
179/// - `408 Request Timeout` and `429 Too Many Requests` are the standard client-side signals
180///   to back off and try again; 429 in particular is the single most common reason a request
181///   should be retried, and rejecting it defeats the point of conditional retry.
182/// - `500`, `502`, `503`, `504` are transient server/gateway failures.
183/// - `529` is the de facto "overloaded" status used by several inference providers.
184///
185/// Other 5xx codes are deliberately excluded: `501 Not Implemented` and `505 HTTP Version Not
186/// Supported` describe permanent server capabilities, so retrying them only wastes attempts.
187const RETRYABLE_STATUS_CODES: &[u16] = &[408, 429, 500, 502, 503, 504, 529];
188
189/// Determine if an error is retryable
190///
191/// Returns true for transient errors like network issues, timeouts, rate limiting (429), and
192/// 5xx server errors. Returns false for client errors like invalid requests (4xx) or
193/// configuration errors.
194///
195/// API errors are classified on [`Error::status_code`]. An API error raised without a status
196/// — via [`Error::api`] rather than [`Error::api_status`] — is treated as non-retryable,
197/// matching the conservative default applied to every other unclassified error.
198pub fn is_retryable_error(error: &Error) -> bool {
199    match error {
200        // Transport failures, timeouts, and interrupted streams are all transient by nature.
201        Error::Http(_) | Error::Timeout | Error::Stream(_) => true,
202        // Everything else is retryable only if it carries a transient HTTP status.
203        // `status_code()` is `None` for every non-API variant, so configuration errors,
204        // invalid input, tool failures, and serialization failures all fall through to false.
205        _ => error
206            .status_code()
207            .is_some_and(|status| RETRYABLE_STATUS_CODES.contains(&status)),
208    }
209}
210
211/// Retry an async operation with exponential backoff, only retrying on retryable errors
212///
213/// This is a smarter version of `retry_with_backoff` that only retries transient errors.
214///
215/// # Examples
216///
217/// ```rust,no_run
218/// use open_agent::retry::{retry_with_backoff_conditional, RetryConfig};
219/// use open_agent::{Client, AgentOptions};
220///
221/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
222/// let config = RetryConfig::default();
223/// let options = AgentOptions::builder()
224///     .model("qwen3:8b")
225///     .base_url("http://localhost:11434/v1")
226///     .build()?;
227///
228/// let result = retry_with_backoff_conditional(config, || async {
229///     let mut client = Client::new(options.clone())?;
230///     client.send("Hello").await?;
231///     Ok::<_, open_agent::Error>(())
232/// }).await?;
233/// # Ok(())
234/// # }
235/// ```
236pub async fn retry_with_backoff_conditional<F, Fut, T>(
237    config: RetryConfig,
238    mut operation: F,
239) -> Result<T>
240where
241    F: FnMut() -> Fut,
242    Fut: Future<Output = Result<T>>,
243{
244    let mut last_error = None;
245
246    for attempt in 0..config.max_attempts {
247        match operation().await {
248            Ok(result) => return Ok(result),
249            Err(err) => {
250                // Check if error is retryable
251                if !is_retryable_error(&err) {
252                    return Err(err);
253                }
254
255                last_error = Some(err);
256
257                // Don't sleep after the last attempt
258                if attempt < config.max_attempts - 1 {
259                    let delay = config.calculate_delay(attempt);
260                    sleep(delay).await;
261                }
262            }
263        }
264    }
265
266    Err(last_error.unwrap_or_else(|| Error::other("Retry failed with no error")))
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    include!("retry/tests.rs");
274}