use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use super::{LlmResponse, LlmService, Message, ToolSchema};
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub max_retries: u32,
pub base_delay: Duration,
pub max_delay: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(8),
}
}
}
impl RetryPolicy {
fn delay_for(&self, attempt: u32) -> Duration {
let exp = self
.base_delay
.saturating_mul(2u32.saturating_pow(attempt))
.min(self.max_delay);
let jitter_cap = (exp.as_millis() as u64) / 2;
let jitter = if jitter_cap == 0 { 0 } else { rand::random::<u64>() % jitter_cap };
exp + Duration::from_millis(jitter)
}
}
#[derive(Debug)]
pub struct Transient;
impl std::fmt::Display for Transient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "transient error")
}
}
impl std::error::Error for Transient {}
pub fn is_retryable(err: &anyhow::Error) -> bool {
for cause in err.chain() {
if cause.downcast_ref::<Transient>().is_some() {
return true;
}
if let Some(re) = cause.downcast_ref::<reqwest::Error>() {
if re.is_timeout() || re.is_connect() {
return true;
}
if let Some(status) = re.status() {
return matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504);
}
return re.is_request();
}
}
false
}
type BoxFuture<T> = Pin<Box<dyn Future<Output = Result<T>> + Send>>;
pub struct RetryLlm {
inner: Arc<dyn LlmService>,
policy: RetryPolicy,
}
impl RetryLlm {
pub fn new(inner: Arc<dyn LlmService>) -> Self {
Self { inner, policy: RetryPolicy::default() }
}
pub fn with_policy(mut self, policy: RetryPolicy) -> Self {
self.policy = policy;
self
}
async fn with_retries<T>(&self, mut call: impl FnMut() -> BoxFuture<T> + Send) -> Result<T> {
let mut attempt = 0u32;
loop {
match call().await {
Ok(v) => return Ok(v),
Err(err) => {
if attempt >= self.policy.max_retries || !is_retryable(&err) {
return Err(err);
}
let delay = self.policy.delay_for(attempt);
tracing::warn!(attempt = attempt + 1, ?delay, error = %err, "transient LLM error; retrying");
tokio::time::sleep(delay).await;
attempt += 1;
}
}
}
}
}
#[async_trait]
impl LlmService for RetryLlm {
async fn submit_prompt(&self, messages: Vec<Message>) -> Result<String> {
self.with_retries(|| {
let inner = self.inner.clone();
let messages = messages.clone();
Box::pin(async move { inner.submit_prompt(messages).await }) as BoxFuture<String>
})
.await
}
async fn chat(&self, messages: Vec<Message>, tools: &[ToolSchema]) -> Result<LlmResponse> {
self.with_retries(|| {
let inner = self.inner.clone();
let messages = messages.clone();
let tools = tools.to_vec();
Box::pin(async move { inner.chat(messages, &tools).await }) as BoxFuture<LlmResponse>
})
.await
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
struct FlakyLlm {
transient_failures: usize,
permanent: bool,
calls: AtomicUsize,
}
impl FlakyLlm {
fn transient(n: usize) -> Self {
Self { transient_failures: n, permanent: false, calls: AtomicUsize::new(0) }
}
fn permanent() -> Self {
Self { transient_failures: 0, permanent: true, calls: AtomicUsize::new(0) }
}
}
#[async_trait]
impl LlmService for FlakyLlm {
async fn submit_prompt(&self, _messages: Vec<Message>) -> Result<String> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if self.permanent {
anyhow::bail!("401 unauthorized: bad API key");
}
if n < self.transient_failures {
return Err(anyhow::Error::new(Transient).context("simulated 503"));
}
Ok("ok".into())
}
}
fn fast() -> RetryPolicy {
RetryPolicy {
max_retries: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(4),
}
}
#[tokio::test]
async fn succeeds_after_transient_failures() {
let inner = Arc::new(FlakyLlm::transient(2));
let llm = RetryLlm::new(inner.clone()).with_policy(fast());
let out = llm.submit_prompt(vec![Message::user("q")]).await.unwrap();
assert_eq!(out, "ok");
assert_eq!(inner.calls.load(Ordering::SeqCst), 3); }
#[tokio::test]
async fn gives_up_after_max_retries() {
let inner = Arc::new(FlakyLlm::transient(100));
let llm = RetryLlm::new(inner.clone()).with_policy(fast());
let err = llm.submit_prompt(vec![Message::user("q")]).await.unwrap_err();
assert!(is_retryable(&err)); assert_eq!(inner.calls.load(Ordering::SeqCst), 4); }
#[tokio::test]
async fn permanent_errors_are_not_retried() {
let inner = Arc::new(FlakyLlm::permanent());
let llm = RetryLlm::new(inner.clone()).with_policy(fast());
let err = llm.submit_prompt(vec![Message::user("q")]).await.unwrap_err();
assert!(err.to_string().contains("401"));
assert_eq!(inner.calls.load(Ordering::SeqCst), 1); }
#[tokio::test]
async fn chat_path_retries_too() {
let inner = Arc::new(FlakyLlm::transient(1));
let llm = RetryLlm::new(inner.clone()).with_policy(fast());
let out = llm.chat(vec![Message::user("q")], &[]).await.unwrap();
assert_eq!(out.text.as_deref(), Some("ok"));
assert_eq!(inner.calls.load(Ordering::SeqCst), 2);
}
#[test]
fn classification_walks_the_chain() {
let transient = anyhow::Error::new(Transient).context("outer context");
assert!(is_retryable(&transient));
let plain = anyhow::anyhow!("some logic error");
assert!(!is_retryable(&plain));
}
#[test]
fn backoff_is_capped() {
let p = fast();
let d = p.delay_for(30);
assert!(d <= p.max_delay + p.max_delay / 2);
}
}