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 = "ollama", feature = "extract")), 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)]
mod tests {
use super::*;
use std::io;
fn transport(kind: io::ErrorKind, message: &str) -> ureq::Error {
ureq::Error::from(io::Error::new(kind, message.to_owned()))
}
#[test]
fn a_connection_reset_is_replayable() {
assert!(is_retryable(&transport(
io::ErrorKind::ConnectionReset,
"Connection reset by peer (os error 54)"
)));
}
#[test]
fn a_refused_connection_is_replayable() {
assert!(is_retryable(&transport(
io::ErrorKind::ConnectionRefused,
"Connection refused"
)));
}
#[test]
fn a_truncated_body_is_replayable() {
assert!(is_retryable(&transport(
io::ErrorKind::UnexpectedEof,
"response body closed before all bytes were read"
)));
assert!(io_is_retryable(&io::Error::from(
io::ErrorKind::UnexpectedEof
)));
}
#[test]
fn an_exhausted_timeout_is_not_replayable() {
assert!(!is_retryable(&transport(
io::ErrorKind::TimedOut,
"timed out reading response"
)));
assert!(!io_is_retryable(&io::Error::from(io::ErrorKind::TimedOut)));
}
#[test]
fn a_client_error_is_not_replayable() {
let response = ureq::Response::new(404, "Not Found", "model not found").expect("response");
assert!(!is_retryable(&ureq::Error::Status(404, response)));
assert!(!status_is_retryable(400));
assert!(!status_is_retryable(404));
}
#[test]
fn a_server_error_is_replayable() {
let response =
ureq::Response::new(503, "Service Unavailable", "loading").expect("response");
assert!(is_retryable(&ureq::Error::Status(503, response)));
assert!(status_is_retryable(429));
assert!(status_is_retryable(500));
}
#[test]
fn the_classifier_never_reads_the_error_text() {
let terse = transport(io::ErrorKind::ConnectionReset, "");
let chatty = transport(
io::ErrorKind::ConnectionReset,
"the connection was reset while the request timed out mid-flight",
);
assert_eq!(is_retryable(&terse), is_retryable(&chatty));
let quiet = ureq::Response::new(404, "Not Found", "").expect("response");
let loud =
ureq::Response::new(404, "Not Found", "connection reset timeout").expect("response");
assert_eq!(
is_retryable(&ureq::Error::Status(404, quiet)),
is_retryable(&ureq::Error::Status(404, loud))
);
}
#[test]
fn with_retry_stops_early_on_a_non_retryable_error() {
let mut calls = 0_u32;
let outcome: Result<(), (&str, u32)> = with_retry(
&HTTP_RETRIES,
|_| false,
|| {
calls += 1;
Err("deterministic")
},
);
assert_eq!(calls, 1, "a deterministic failure must not be replayed");
assert!(matches!(outcome, Err(("deterministic", 1))));
}
#[test]
fn with_retry_reports_the_attempt_count() {
let outcome: Result<(), (&str, u32)> =
with_retry(&HTTP_RETRIES, |_| true, || Err("transient"));
assert!(
matches!(outcome, Err((_, 3))),
"one attempt plus two replays, and the count must reach the caller"
);
}
#[test]
fn the_backoff_grows_and_stays_capped() {
assert_eq!(HTTP_RETRIES.delay_for_attempt(0), Duration::ZERO);
assert_eq!(
HTTP_RETRIES.delay_for_attempt(1),
Duration::from_millis(100)
);
assert_eq!(
HTTP_RETRIES.delay_for_attempt(2),
Duration::from_millis(200)
);
assert_eq!(
HTTP_RETRIES.delay_for_attempt(99),
HTTP_RETRIES.max_delay,
"the schedule must not drift into a long sleep"
);
}
#[test]
fn the_failure_message_names_the_levers_of_its_own_call_site() {
let message = actionable_ollama_failure(
"embeddings",
"http://localhost:11434/api/embeddings",
"all-minilm",
3,
"Network Error: Connection reset by peer (os error 54)",
&FailureLevers {
url_var: "VELESDB_MEMORY_OLLAMA_URL",
model_var: "VELESDB_MEMORY_OLLAMA_MODEL",
fallback: Some(
"fall back to the offline embedder with VELESDB_MEMORY_EMBEDDER=hash",
),
},
);
assert!(message.contains("3 attempts"));
assert!(message.contains("http://localhost:11434/api/embeddings"));
assert!(message.contains("all-minilm"));
assert!(message.contains("VELESDB_MEMORY_OLLAMA_URL"));
assert!(message.contains("VELESDB_MEMORY_EMBEDDER=hash"));
}
}