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}