use std::time::Duration;
use async_trait::async_trait;
use tracing::{debug, warn};
use tokio::sync::mpsc;
use crate::error::{ProviderError, Result};
use crate::provider::Provider;
use crate::types::{ChatRequest, ChatResponse, StreamChunk};
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub base_delay: Duration,
pub max_delay: Duration,
pub jitter_fraction: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(30),
jitter_fraction: 0.25,
}
}
}
pub fn is_retryable(err: &ProviderError) -> bool {
match err {
ProviderError::RateLimited { .. } => true,
ProviderError::Timeout => true,
ProviderError::Http(_) => true,
ProviderError::ServerError { status, .. } => (500..=599).contains(status),
ProviderError::RequestFailed(_) => false,
ProviderError::AuthFailed(_)
| ProviderError::ModelNotFound(_)
| ProviderError::NotConfigured(_)
| ProviderError::InvalidResponse(_)
| ProviderError::Json(_)
| ProviderError::AllProvidersExhausted { .. } => false,
}
}
pub fn compute_delay(config: &RetryConfig, attempt: u32) -> Duration {
let exp = 2u64.saturating_pow(attempt);
let base_ms = config.base_delay.as_millis() as u64;
let raw_ms = base_ms.saturating_mul(exp);
let capped_ms = raw_ms.min(config.max_delay.as_millis() as u64);
let jitter_max_ms = (capped_ms as f64 * config.jitter_fraction) as u64;
let jitter_ms = if jitter_max_ms > 0 {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos() as u64;
seed % (jitter_max_ms + 1)
} else {
0
};
Duration::from_millis(capped_ms + jitter_ms)
}
pub struct RetryPolicy<P> {
inner: P,
config: RetryConfig,
}
impl<P: Provider> RetryPolicy<P> {
pub fn new(inner: P, config: RetryConfig) -> Self {
Self { inner, config }
}
pub fn retry_config(&self) -> &RetryConfig {
&self.config
}
pub fn inner(&self) -> &P {
&self.inner
}
}
#[async_trait]
impl<P: Provider> Provider for RetryPolicy<P> {
fn name(&self) -> &str {
self.inner.name()
}
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse> {
let mut last_err = None;
for attempt in 0..=self.config.max_retries {
match self.inner.complete(request).await {
Ok(response) => {
if attempt > 0 {
debug!(
provider = %self.inner.name(),
attempt,
"request succeeded after retry"
);
}
return Ok(response);
}
Err(err) => {
if !is_retryable(&err) || attempt == self.config.max_retries {
return Err(err);
}
let delay = if let ProviderError::RateLimited { retry_after_ms } = &err {
let computed = compute_delay(&self.config, attempt);
let suggested = Duration::from_millis(*retry_after_ms);
computed.max(suggested)
} else {
compute_delay(&self.config, attempt)
};
warn!(
provider = %self.inner.name(),
attempt,
delay_ms = delay.as_millis() as u64,
error = %err,
"retrying after transient error"
);
tokio::time::sleep(delay).await;
last_err = Some(err);
}
}
}
Err(last_err.unwrap_or(ProviderError::RequestFailed(
"retry loop exhausted without error".into(),
)))
}
async fn complete_stream(
&self,
request: &ChatRequest,
tx: mpsc::Sender<StreamChunk>,
) -> Result<()> {
let mut last_err = None;
for attempt in 0..=self.config.max_retries {
let (attempt_tx, mut attempt_rx) = mpsc::channel::<StreamChunk>(256);
match self.inner.complete_stream(request, attempt_tx).await {
Ok(()) => {
if attempt > 0 {
debug!(
provider = %self.inner.name(),
attempt,
"streaming request succeeded after retry"
);
}
while let Some(chunk) = attempt_rx.recv().await {
if tx.send(chunk).await.is_err() {
break;
}
}
return Ok(());
}
Err(err) => {
drop(attempt_rx);
if !is_retryable(&err) || attempt == self.config.max_retries {
return Err(err);
}
let delay = if let ProviderError::RateLimited { retry_after_ms } = &err {
let computed = compute_delay(&self.config, attempt);
let suggested = Duration::from_millis(*retry_after_ms);
computed.max(suggested)
} else {
compute_delay(&self.config, attempt)
};
warn!(
provider = %self.inner.name(),
attempt,
delay_ms = delay.as_millis() as u64,
error = %err,
"retrying streaming request after transient error"
);
tokio::time::sleep(delay).await;
last_err = Some(err);
}
}
}
Err(last_err.unwrap_or(ProviderError::RequestFailed(
"streaming retry loop exhausted without error".into(),
)))
}
}
impl<P: std::fmt::Debug> std::fmt::Debug for RetryPolicy<P> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RetryPolicy")
.field("inner", &self.inner)
.field("config", &self.config)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ChatMessage, ChatResponse, Choice, Usage};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
struct MockProvider {
name: String,
fail_count: AtomicU32,
fail_with: fn(u32) -> ProviderError,
}
impl MockProvider {
fn new(name: &str, failures: u32, fail_with: fn(u32) -> ProviderError) -> Self {
Self {
name: name.into(),
fail_count: AtomicU32::new(failures),
fail_with,
}
}
fn success_response() -> ChatResponse {
ChatResponse {
id: "resp-1".into(),
choices: vec![Choice {
index: 0,
message: ChatMessage::assistant("Hello!"),
finish_reason: Some("stop".into()),
}],
usage: Some(Usage {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
}),
model: "test-model".into(),
}
}
}
#[async_trait]
impl Provider for MockProvider {
fn name(&self) -> &str {
&self.name
}
async fn complete(&self, _request: &ChatRequest) -> Result<ChatResponse> {
let remaining = self.fail_count.load(Ordering::SeqCst);
if remaining > 0 {
self.fail_count.fetch_sub(1, Ordering::SeqCst);
return Err((self.fail_with)(remaining));
}
Ok(Self::success_response())
}
}
fn test_request() -> ChatRequest {
ChatRequest::new("test-model", vec![ChatMessage::user("Hi")])
}
fn fast_retry_config() -> RetryConfig {
RetryConfig {
max_retries: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(10),
jitter_fraction: 0.0,
}
}
#[test]
fn default_retry_config() {
let cfg = RetryConfig::default();
assert_eq!(cfg.max_retries, 3);
assert_eq!(cfg.base_delay, Duration::from_secs(1));
assert_eq!(cfg.max_delay, Duration::from_secs(30));
assert!((cfg.jitter_fraction - 0.25).abs() < f64::EPSILON);
}
#[test]
fn is_retryable_rate_limited() {
assert!(is_retryable(&ProviderError::RateLimited {
retry_after_ms: 1000,
}));
}
#[test]
fn is_retryable_timeout() {
assert!(is_retryable(&ProviderError::Timeout));
}
#[test]
fn is_retryable_server_errors() {
assert!(is_retryable(&ProviderError::ServerError {
status: 500,
body: "internal".into(),
}));
assert!(is_retryable(&ProviderError::ServerError {
status: 502,
body: "bad gateway".into(),
}));
assert!(is_retryable(&ProviderError::ServerError {
status: 503,
body: "unavailable".into(),
}));
assert!(is_retryable(&ProviderError::ServerError {
status: 504,
body: "timeout".into(),
}));
}
#[test]
fn is_not_retryable_auth() {
assert!(!is_retryable(&ProviderError::AuthFailed("bad key".into())));
}
#[test]
fn is_not_retryable_model_not_found() {
assert!(!is_retryable(&ProviderError::ModelNotFound("gpt-5".into())));
}
#[test]
fn is_not_retryable_not_configured() {
assert!(!is_retryable(&ProviderError::NotConfigured(
"missing key".into()
)));
}
#[test]
fn is_not_retryable_invalid_response() {
assert!(!is_retryable(&ProviderError::InvalidResponse(
"bad json".into()
)));
}
#[test]
fn is_not_retryable_client_error() {
assert!(!is_retryable(&ProviderError::RequestFailed(
"HTTP 400: bad request".into()
)));
}
#[test]
fn is_retryable_server_error_variant() {
assert!(is_retryable(&ProviderError::ServerError {
status: 500,
body: "internal server error".into(),
}));
assert!(!is_retryable(&ProviderError::ServerError {
status: 400,
body: "bad request".into(),
}));
}
#[test]
fn compute_delay_exponential() {
let config = RetryConfig {
max_retries: 5,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(60),
jitter_fraction: 0.0,
};
let d0 = compute_delay(&config, 0);
assert_eq!(d0.as_millis(), 100);
let d1 = compute_delay(&config, 1);
assert_eq!(d1.as_millis(), 200);
let d2 = compute_delay(&config, 2);
assert_eq!(d2.as_millis(), 400);
}
#[test]
fn compute_delay_capped() {
let config = RetryConfig {
max_retries: 10,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(5),
jitter_fraction: 0.0,
};
let d = compute_delay(&config, 5);
assert_eq!(d.as_millis(), 5000);
}
#[test]
fn compute_delay_with_jitter_bounded() {
let config = RetryConfig {
max_retries: 3,
base_delay: Duration::from_millis(1000),
max_delay: Duration::from_secs(30),
jitter_fraction: 0.25,
};
for _ in 0..20 {
let d = compute_delay(&config, 0);
let ms = d.as_millis();
assert!(ms >= 1000, "delay {ms} < 1000");
assert!(ms <= 1250, "delay {ms} > 1250");
}
}
#[tokio::test]
async fn retry_succeeds_first_try() {
let mock = MockProvider::new("test", 0, |_| ProviderError::Timeout);
let provider = RetryPolicy::new(mock, fast_retry_config());
let resp = provider.complete(&test_request()).await.unwrap();
assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello!"));
}
#[tokio::test]
async fn retry_succeeds_after_transient_failures() {
let mock = MockProvider::new("test", 2, |_| ProviderError::ServerError {
status: 503,
body: "unavailable".into(),
});
let provider = RetryPolicy::new(mock, fast_retry_config());
let resp = provider.complete(&test_request()).await.unwrap();
assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello!"));
}
#[tokio::test]
async fn retry_exhausted_returns_last_error() {
let mock = MockProvider::new("test", 10, |_| ProviderError::ServerError {
status: 500,
body: "error".into(),
});
let config = RetryConfig {
max_retries: 2,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(5),
jitter_fraction: 0.0,
};
let provider = RetryPolicy::new(mock, config);
let err = provider.complete(&test_request()).await.unwrap_err();
assert!(matches!(err, ProviderError::ServerError { .. }));
}
#[tokio::test]
async fn retry_does_not_retry_auth_errors() {
let call_count = Arc::new(AtomicU32::new(0));
struct CountingProvider {
count: Arc<AtomicU32>,
}
#[async_trait]
impl Provider for CountingProvider {
fn name(&self) -> &str {
"counting"
}
async fn complete(&self, _req: &ChatRequest) -> Result<ChatResponse> {
self.count.fetch_add(1, Ordering::SeqCst);
Err(ProviderError::AuthFailed("invalid key".into()))
}
}
let provider = RetryPolicy::new(
CountingProvider {
count: call_count.clone(),
},
fast_retry_config(),
);
let err = provider.complete(&test_request()).await.unwrap_err();
assert!(matches!(err, ProviderError::AuthFailed(_)));
assert_eq!(call_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn retry_rate_limited_uses_suggested_delay() {
let mock = MockProvider::new("test", 1, |_| ProviderError::RateLimited {
retry_after_ms: 5,
});
let config = RetryConfig {
max_retries: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(100),
jitter_fraction: 0.0,
};
let provider = RetryPolicy::new(mock, config);
let resp = provider.complete(&test_request()).await.unwrap();
assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello!"));
}
#[test]
fn retry_policy_name_delegates() {
let mock = MockProvider::new("my-provider", 0, |_| ProviderError::Timeout);
let provider = RetryPolicy::new(mock, RetryConfig::default());
assert_eq!(provider.name(), "my-provider");
}
#[test]
fn retry_config_accessor() {
let mock = MockProvider::new("test", 0, |_| ProviderError::Timeout);
let config = RetryConfig {
max_retries: 5,
..RetryConfig::default()
};
let provider = RetryPolicy::new(mock, config);
assert_eq!(provider.retry_config().max_retries, 5);
}
}