use std::future::Future;
use crate::error::LanguageModelError;
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_attempts: u32,
pub initial_backoff_ms: u64,
pub max_backoff_ms: u64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 3,
initial_backoff_ms: 500,
max_backoff_ms: 30_000,
}
}
}
const fn is_retryable(err: &LanguageModelError) -> bool {
matches!(
err,
LanguageModelError::RateLimited { .. } | LanguageModelError::Provider { .. }
)
}
#[tracing::instrument(
skip(config, f),
fields(max_attempts = config.max_attempts, attempts_taken = tracing::field::Empty),
err(Display),
)]
pub async fn with_retry<F, Fut, T>(config: &RetryConfig, f: F) -> Result<T, LanguageModelError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, LanguageModelError>>,
{
let mut last_err = None;
let mut backoff_ms = config.initial_backoff_ms;
let max_backoff = std::time::Duration::from_millis(config.max_backoff_ms);
for attempt in 0..config.max_attempts {
match f().await {
Ok(val) => {
tracing::Span::current().record("attempts_taken", attempt + 1);
return Ok(val);
}
Err(err) => {
if !is_retryable(&err) || attempt + 1 >= config.max_attempts {
tracing::Span::current().record("attempts_taken", attempt + 1);
if attempt + 1 >= config.max_attempts && is_retryable(&err) {
tracing::error!(
attempt = attempt + 1,
error = %err,
"language-model retry exhausted",
);
}
return Err(err);
}
let sleep_for = if let LanguageModelError::RateLimited {
retry_after: Some(d),
..
} = &err
{
backoff_ms = config.initial_backoff_ms;
(*d).min(max_backoff)
} else {
let jitter = jitter_ms(backoff_ms);
let actual_ms = backoff_ms.saturating_sub(jitter)
+ (simple_hash(attempt) % (jitter * 2 + 1));
std::time::Duration::from_millis(actual_ms)
};
tracing::warn!(
attempt = attempt + 1,
backoff_ms = u64::try_from(sleep_for.as_millis()).unwrap_or(u64::MAX),
error = %err,
"language-model call failed, retrying",
);
last_err = Some(err);
tokio::time::sleep(sleep_for).await;
backoff_ms = (backoff_ms * 2).min(config.max_backoff_ms);
}
}
}
tracing::Span::current().record("attempts_taken", config.max_attempts);
Err(last_err.unwrap_or(LanguageModelError::EmptyResponse))
}
#[expect(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
reason = "jitter is a sleep duration, never a semantic value: at f64 precision \
loss or truncation the worst outcome is a slightly-different sleep \
length, which is already expected behaviour"
)]
fn jitter_ms(backoff_ms: u64) -> u64 {
(backoff_ms as f64 * 0.25) as u64
}
const fn simple_hash(attempt: u32) -> u64 {
let mut h = attempt as u64;
h = h.wrapping_mul(6_364_136_223_846_793_005);
h = h.wrapping_add(1_442_695_040_888_963_407);
h
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
use super::*;
#[tokio::test]
async fn succeeds_on_first_attempt() {
let config = RetryConfig {
max_attempts: 3,
initial_backoff_ms: 1,
max_backoff_ms: 10,
};
let result = with_retry(&config, || async { Ok::<_, LanguageModelError>(42) }).await;
assert_eq!(result.unwrap(), 42);
}
#[tokio::test]
async fn retries_on_rate_limited() {
let attempts = AtomicU32::new(0);
let config = RetryConfig {
max_attempts: 3,
initial_backoff_ms: 1,
max_backoff_ms: 10,
};
let result = with_retry(&config, || {
let n = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if n < 2 {
Err(LanguageModelError::rate_limited("slow down"))
} else {
Ok(42)
}
}
})
.await;
assert_eq!(result.unwrap(), 42);
assert_eq!(attempts.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn retries_on_provider_error() {
let attempts = AtomicU32::new(0);
let config = RetryConfig {
max_attempts: 2,
initial_backoff_ms: 1,
max_backoff_ms: 10,
};
let result = with_retry(&config, || {
let n = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if n < 1 {
Err(LanguageModelError::provider("server error"))
} else {
Ok(42)
}
}
})
.await;
assert_eq!(result.unwrap(), 42);
}
#[tokio::test]
async fn does_not_retry_authentication() {
let attempts = AtomicU32::new(0);
let config = RetryConfig {
max_attempts: 3,
initial_backoff_ms: 1,
max_backoff_ms: 10,
};
let result = with_retry(&config, || {
attempts.fetch_add(1, Ordering::SeqCst);
async { Err::<i32, _>(LanguageModelError::authentication("bad key")) }
})
.await;
assert!(result.is_err());
assert_eq!(attempts.load(Ordering::SeqCst), 1); }
#[tokio::test]
async fn does_not_retry_empty_response() {
let attempts = AtomicU32::new(0);
let config = RetryConfig {
max_attempts: 3,
initial_backoff_ms: 1,
max_backoff_ms: 10,
};
let result = with_retry(&config, || {
attempts.fetch_add(1, Ordering::SeqCst);
async { Err::<i32, _>(LanguageModelError::EmptyResponse) }
})
.await;
assert!(result.is_err());
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn returns_last_error_on_exhaustion() {
let config = RetryConfig {
max_attempts: 2,
initial_backoff_ms: 1,
max_backoff_ms: 10,
};
let result = with_retry(&config, || async {
Err::<i32, _>(LanguageModelError::rate_limited("always failing"))
})
.await;
assert!(matches!(
result.unwrap_err(),
LanguageModelError::RateLimited { .. }
));
}
#[tokio::test]
async fn respects_retry_after_when_present() {
let attempts = AtomicU32::new(0);
let config = RetryConfig {
max_attempts: 3,
initial_backoff_ms: 1,
max_backoff_ms: 5_000,
};
let start = std::time::Instant::now();
let result = with_retry(&config, || {
let n = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if n < 1 {
Err(LanguageModelError::rate_limited_after(
"slow down",
std::time::Duration::from_millis(50),
))
} else {
Ok(42)
}
}
})
.await;
assert_eq!(result.unwrap(), 42);
assert!(
start.elapsed() >= std::time::Duration::from_millis(50),
"expected to honour 50ms Retry-After, slept {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn caps_retry_after_at_max_backoff() {
let attempts = AtomicU32::new(0);
let config = RetryConfig {
max_attempts: 2,
initial_backoff_ms: 1,
max_backoff_ms: 30,
};
let start = std::time::Instant::now();
let _ = with_retry(&config, || {
attempts.fetch_add(1, Ordering::SeqCst);
async {
Err::<i32, _>(LanguageModelError::rate_limited_after(
"absurd hint",
std::time::Duration::from_secs(3600),
))
}
})
.await;
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"max_backoff_ms cap should bound the sleep, slept {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn falls_back_to_exp_backoff_when_retry_after_none() {
let attempts = AtomicU32::new(0);
let config = RetryConfig {
max_attempts: 3,
initial_backoff_ms: 1,
max_backoff_ms: 10,
};
let result = with_retry(&config, || {
let n = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if n < 2 {
Err(LanguageModelError::rate_limited("no header"))
} else {
Ok(7)
}
}
})
.await;
assert_eq!(result.unwrap(), 7);
assert_eq!(attempts.load(Ordering::SeqCst), 3);
}
}