use std::time::Duration;
#[derive(Debug, Clone)]
pub(crate) struct RetryConfig {
max_retries: u32,
initial_delay: Duration,
max_delay: Duration,
backoff_multiplier: f64,
}
pub(crate) const HTTP_RETRIES: RetryConfig = RetryConfig {
max_retries: 2,
initial_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(1),
backoff_multiplier: 2.0,
};
impl RetryConfig {
fn delay_for_attempt(&self, attempt: u32) -> Duration {
if attempt == 0 {
return Duration::ZERO;
}
let exponent = i32::try_from(attempt.saturating_sub(1)).unwrap_or(i32::MAX);
let seconds = self.initial_delay.as_secs_f64() * self.backoff_multiplier.powi(exponent);
Duration::from_secs_f64(seconds.min(self.max_delay.as_secs_f64()))
}
}
pub(crate) fn status_is_retryable(status: u16) -> bool {
status >= 500 || status == 429
}
pub(crate) fn io_is_retryable(err: &std::io::Error) -> bool {
!matches!(
err.kind(),
std::io::ErrorKind::TimedOut
| std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::PermissionDenied
| std::io::ErrorKind::InvalidInput
)
}
fn kind_is_transient(kind: ureq::ErrorKind) -> bool {
matches!(
kind,
ureq::ErrorKind::Dns
| ureq::ErrorKind::ConnectionFailed
| ureq::ErrorKind::ProxyConnect
| ureq::ErrorKind::Io
)
}
pub(crate) fn is_retryable(err: &ureq::Error) -> bool {
match err {
ureq::Error::Status(status, _) => status_is_retryable(*status),
ureq::Error::Transport(transport) => {
if !kind_is_transient(transport.kind()) {
return false;
}
std::error::Error::source(transport)
.and_then(|source| source.downcast_ref::<std::io::Error>())
.is_none_or(io_is_retryable)
}
}
}
pub(crate) fn with_retry<T, E>(
config: &RetryConfig,
retryable: impl Fn(&E) -> bool,
mut op: impl FnMut() -> Result<T, E>,
) -> Result<T, (E, u32)> {
let mut attempts: u32 = 0;
loop {
attempts = attempts.saturating_add(1);
let err = match op() {
Ok(value) => return Ok(value),
Err(err) => err,
};
if attempts > config.max_retries || !retryable(&err) {
return Err((err, attempts));
}
std::thread::sleep(config.delay_for_attempt(attempts));
}
}
pub(crate) struct FailureLevers<'a> {
pub url_var: &'a str,
pub model_var: &'a str,
pub fallback: Option<&'a str>,
}
pub(crate) fn actionable_ollama_failure(
endpoint: &str,
url: &str,
model: &str,
attempts: u32,
cause: &str,
levers: &FailureLevers<'_>,
) -> String {
let plural = if attempts == 1 { "attempt" } else { "attempts" };
let mut message = format!(
"ollama {endpoint} call failed after {attempts} {plural}: POST {url} \
(model '{model}'): {cause}. Check Ollama is running and the model is \
pulled (`ollama pull {model}`). Point elsewhere with {} / {}",
levers.url_var, levers.model_var
);
if let Some(fallback) = levers.fallback {
message.push_str(", or ");
message.push_str(fallback);
}
message.push('.');
message
}
#[cfg_attr(
not(any(feature = "embedder-http", feature = "extractor-http")),
allow(dead_code)
)]
pub(crate) fn actionable_openai_failure(
endpoint: &str,
url: &str,
model: &str,
attempts: u32,
cause: &str,
hint: Option<&str>,
) -> String {
let plural = if attempts == 1 { "attempt" } else { "attempts" };
let mut message = format!(
"openai-compatible {endpoint} call failed after {attempts} {plural}: \
POST {url} (model '{model}'): {cause}. Check a server is listening at \
that base URL and serves that model"
);
if let Some(hint) = hint {
message.push_str(", or ");
message.push_str(hint);
}
message.push('.');
message
}
#[cfg(test)]
#[path = "http_retry_tests.rs"]
mod tests;