use super::{BoxFuture, Middleware, NextFn};
use crate::client::ClientRequest;
use crate::errors::SDKError;
use crate::response::ClientResponse;
use std::time::Duration;
use tracing::{debug, warn};
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_attempts: u32,
pub delay: Duration,
pub backoff_multiplier: f32,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 3,
delay: Duration::from_millis(500),
backoff_multiplier: 1.5,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RetryMiddleware {
config: RetryConfig,
}
impl RetryMiddleware {
#[must_use]
pub fn new() -> Self {
Self {
config: RetryConfig::default(),
}
}
#[must_use]
pub fn with_config(config: RetryConfig) -> Self {
Self { config }
}
fn is_retriable(error: &SDKError) -> bool {
match error {
SDKError::Network(_) => true,
SDKError::Api(api_err) => {
if let Some(code) = api_err.status_code {
code == 429 || (500..=599).contains(&code)
} else {
let msg = api_err.message.to_lowercase();
msg.contains("rate limit") || msg.contains("50")
}
}
_ => false,
}
}
}
impl Middleware for RetryMiddleware {
fn execute<'a>(
&'a self,
request: ClientRequest,
next: NextFn<'a>,
) -> BoxFuture<'a, Result<ClientResponse, SDKError>> {
Box::pin(async move {
let max_attempts = self.config.max_attempts.max(1);
let mut attempt = 0u32;
loop {
attempt += 1;
debug!(
attempt,
max_attempts, "Executing request via retry middleware"
);
let result = next(request.clone()).await;
match &result {
Err(e) if attempt < max_attempts && Self::is_retriable(e) => {
let exponent = i32::try_from(attempt - 1).unwrap_or(i32::MAX);
let delay = self
.config
.delay
.mul_f32(self.config.backoff_multiplier.powi(exponent));
warn!(
attempt,
max_attempts,
error = %e,
delay_ms = delay.as_millis(),
"Request failed with retriable error; retrying after backoff"
);
tokio::time::sleep(delay).await;
}
_ => return result,
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::Conversation;
use crate::errors::ApiError;
use crate::message::{Message, Role};
use crate::middleware::MiddlewarePipeline;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[tokio::test]
async fn test_retry_middleware() {
let mut pipeline = MiddlewarePipeline::new();
pipeline.add(RetryMiddleware::new());
let request = ClientRequest::new(Conversation::new());
let handler = |_req: ClientRequest| async move {
Ok(ClientResponse::new(Message::new(
Role::Assistant,
"Success",
)))
};
let res = pipeline.execute(request, handler).await.unwrap();
assert_eq!(res.message.content(), "Success");
}
#[tokio::test]
async fn test_retry_middleware_retries_on_retriable_error() {
let mut pipeline = MiddlewarePipeline::new();
pipeline.add(RetryMiddleware::with_config(RetryConfig {
max_attempts: 3,
delay: Duration::from_millis(1),
backoff_multiplier: 1.0,
}));
let request = ClientRequest::new(Conversation::new());
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = Arc::clone(&calls);
let handler = move |_req: ClientRequest| {
let calls = Arc::clone(&calls_clone);
async move {
let attempt = calls.fetch_add(1, Ordering::SeqCst) + 1;
if attempt < 3 {
Err(SDKError::Api(ApiError::with_status("server error", 500)))
} else {
Ok(ClientResponse::new(Message::new(
Role::Assistant,
"Success",
)))
}
}
};
let res = pipeline.execute(request, handler).await.unwrap();
assert_eq!(res.message.content(), "Success");
let call_count = calls.load(Ordering::SeqCst);
assert!(
call_count > 1,
"expected more than one attempt, got {call_count}"
);
assert_eq!(call_count, 3);
}
#[tokio::test]
async fn test_retry_middleware_does_not_retry_non_retriable_error() {
let mut pipeline = MiddlewarePipeline::new();
pipeline.add(RetryMiddleware::with_config(RetryConfig {
max_attempts: 3,
delay: Duration::from_millis(1),
backoff_multiplier: 1.0,
}));
let request = ClientRequest::new(Conversation::new());
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = Arc::clone(&calls);
let handler = move |_req: ClientRequest| {
let calls = Arc::clone(&calls_clone);
async move {
calls.fetch_add(1, Ordering::SeqCst);
Err::<ClientResponse, _>(SDKError::Api(ApiError::with_status("bad request", 400)))
}
};
let res = pipeline.execute(request, handler).await;
assert!(res.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_retry_middleware_retries_network_error() {
let mut pipeline = MiddlewarePipeline::new();
pipeline.add(RetryMiddleware::with_config(RetryConfig {
max_attempts: 3,
delay: Duration::from_millis(1),
backoff_multiplier: 1.0,
}));
let request = ClientRequest::new(Conversation::new());
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = Arc::clone(&calls);
let handler = move |_req: ClientRequest| {
let calls = Arc::clone(&calls_clone);
async move {
let attempt = calls.fetch_add(1, Ordering::SeqCst) + 1;
if attempt < 2 {
Err(SDKError::Network(crate::errors::NetworkError::new(
"connection reset",
)))
} else {
Ok(ClientResponse::new(Message::new(
Role::Assistant,
"Success",
)))
}
}
};
let res = pipeline.execute(request, handler).await.unwrap();
assert_eq!(res.message.content(), "Success");
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_retry_is_retriable_message_fallback() {
assert!(RetryMiddleware::is_retriable(&SDKError::Api(
ApiError::new("Rate limit exceeded, please slow down")
)));
assert!(!RetryMiddleware::is_retriable(&SDKError::Api(
ApiError::new("resource not found")
)));
}
#[tokio::test(start_paused = true)]
async fn test_retry_middleware_backoff_grows_exponentially() {
let mut pipeline = MiddlewarePipeline::new();
pipeline.add(RetryMiddleware::with_config(RetryConfig {
max_attempts: 3,
delay: Duration::from_millis(10),
backoff_multiplier: 2.0,
}));
let request = ClientRequest::new(Conversation::new());
let timestamps = Arc::new(std::sync::Mutex::new(Vec::new()));
let timestamps_clone = Arc::clone(×tamps);
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = Arc::clone(&calls);
let handler = move |_req: ClientRequest| {
let calls = Arc::clone(&calls_clone);
let timestamps = Arc::clone(×tamps_clone);
async move {
timestamps.lock().unwrap().push(tokio::time::Instant::now());
let attempt = calls.fetch_add(1, Ordering::SeqCst) + 1;
if attempt < 3 {
Err(SDKError::Api(ApiError::with_status("server error", 500)))
} else {
Ok(ClientResponse::new(Message::new(
Role::Assistant,
"Success",
)))
}
}
};
pipeline.execute(request, handler).await.unwrap();
let ts = timestamps.lock().unwrap();
assert_eq!(ts.len(), 3);
let first_gap = ts[1] - ts[0];
let second_gap = ts[2] - ts[1];
assert_eq!(first_gap, Duration::from_millis(10));
assert_eq!(second_gap, Duration::from_millis(20));
}
#[tokio::test]
async fn test_retry_middleware_zero_max_attempts_still_calls_once() {
let mut pipeline = MiddlewarePipeline::new();
pipeline.add(RetryMiddleware::with_config(RetryConfig {
max_attempts: 0,
delay: Duration::from_millis(1),
backoff_multiplier: 1.0,
}));
let request = ClientRequest::new(Conversation::new());
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = Arc::clone(&calls);
let handler = move |_req: ClientRequest| {
let calls = Arc::clone(&calls_clone);
async move {
calls.fetch_add(1, Ordering::SeqCst);
Err::<ClientResponse, _>(SDKError::Api(ApiError::with_status("server error", 500)))
}
};
let res = pipeline.execute(request, handler).await;
assert!(res.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}