use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use backon::{ExponentialBuilder, Retryable};
use deadpool::managed::{Object, PoolError};
use crate::error::{Error, Result};
use crate::tcp::pool::{NativePool, TcpConnectionManager};
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct RetryPolicy {
pub max_attempts: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(2),
}
}
}
impl RetryPolicy {
fn schedule(self) -> ExponentialBuilder {
ExponentialBuilder::default()
.with_min_delay(self.initial_backoff)
.with_max_delay(self.max_backoff)
.with_max_times(self.max_attempts.saturating_sub(1) as usize)
.with_jitter()
}
}
pub fn is_retriable_transport(e: &Error) -> bool {
if e.is_retriable() {
return true;
}
match e {
Error::Other(boxed) => boxed
.downcast_ref::<std::io::Error>()
.is_some_and(is_transient_io_kind),
_ => false,
}
}
fn is_transient_io_kind(e: &std::io::Error) -> bool {
use std::io::ErrorKind;
matches!(
e.kind(),
ErrorKind::ConnectionRefused
| ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::NotConnected
| ErrorKind::BrokenPipe
| ErrorKind::TimedOut
| ErrorKind::Interrupted
| ErrorKind::UnexpectedEof
| ErrorKind::AddrNotAvailable
| ErrorKind::HostUnreachable
| ErrorKind::NetworkUnreachable
| ErrorKind::NetworkDown
)
}
#[must_use]
pub fn map_pool_error(e: PoolError<Error>) -> Error {
match e {
PoolError::Backend(err) => err,
other => Error::Transient(format!("tcp pool: {other}")),
}
}
pub async fn run_with_retry<T, F, Fut>(
pool: &Arc<NativePool>,
retry: Option<RetryPolicy>,
op: F,
) -> Result<T>
where
F: Fn(Object<TcpConnectionManager>) -> Fut,
Fut: Future<Output = Result<T>>,
{
let attempt_once = || async {
let conn = pool.get().await.map_err(map_pool_error)?;
op(conn).await
};
retry_with(retry, attempt_once).await
}
async fn retry_with<T, A, Fut>(retry: Option<RetryPolicy>, attempt_once: A) -> Result<T>
where
A: Fn() -> Fut,
Fut: Future<Output = Result<T>>,
{
match retry {
Some(policy) if policy.max_attempts > 1 => {
attempt_once
.retry(policy.schedule())
.when(is_retriable_transport)
.sleep(tokio::time::sleep)
.await
}
_ => attempt_once().await,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::time::Duration;
fn policy(max_attempts: u32, initial_ms: u64, max_ms: u64) -> RetryPolicy {
RetryPolicy {
max_attempts,
initial_backoff: Duration::from_millis(initial_ms),
max_backoff: Duration::from_millis(max_ms),
}
}
fn server_error() -> Error {
Error::ServerException {
code: 60,
name: None,
message: "no such table".to_string(),
stack_trace: None,
}
}
fn transient_pool_error() -> Error {
Error::Transient("tcp pool: Timeout occurred while creating a new object".to_string())
}
#[test]
fn is_retriable_transport_classification() {
assert!(is_retriable_transport(&Error::TimedOut));
assert!(is_retriable_transport(&transient_pool_error()));
assert!(is_retriable_transport(&Error::Connect(
"tcp: cannot resolve \"bad:9000\"".to_string()
)));
assert!(is_retriable_transport(&Error::ServerException {
code: 209, name: None,
message: String::new(),
stack_trace: None,
}));
assert!(is_retriable_transport(&Error::Connect(
"tcp: \"bad:9000\" resolved to no addresses".to_string()
)));
assert!(!is_retriable_transport(&server_error()));
assert!(!is_retriable_transport(&Error::Custom(
"some serde failure".to_string()
)));
for non_transient in [
"tcp: actor busy in INSERT",
"tcp: no INSERT session active",
"tcp: INSERT block of 99 bytes exceeds the cap",
"tcp: TLS requested but the `tls` feature is not enabled",
"tcp pool: a message that only looks like a pool error",
] {
assert!(
!is_retriable_transport(&Error::Custom(non_transient.to_string())),
"must NOT retry: {non_transient}"
);
}
}
#[test]
fn is_retriable_transport_classifies_connect_io_kinds() {
use std::io;
let refused: Error = io::Error::new(io::ErrorKind::ConnectionRefused, "refused").into();
assert!(matches!(refused, Error::Other(_)));
assert!(is_retriable_transport(&refused));
let reset: Error = io::Error::new(io::ErrorKind::ConnectionReset, "reset").into();
assert!(is_retriable_transport(&reset));
let denied: Error = io::Error::new(io::ErrorKind::PermissionDenied, "denied").into();
assert!(matches!(denied, Error::Other(_)));
assert!(!is_retriable_transport(&denied));
let other = Error::Other("plain string boxed".into());
assert!(!is_retriable_transport(&other));
}
#[tokio::test(start_paused = true)]
async fn retry_then_succeed_stays_within_the_backoff_bounds() {
let calls = Cell::new(0u32);
let p = policy(3, 100, 2000);
let start = tokio::time::Instant::now();
let r: Result<u32> = retry_with(Some(p), || {
let n = calls.get() + 1;
calls.set(n);
async move { if n < 3 { Err(Error::TimedOut) } else { Ok(n) } }
})
.await;
assert_eq!(r.unwrap(), 3);
assert_eq!(calls.get(), 3, "should have run exactly three attempts");
assert!(
start.elapsed() <= Duration::from_millis(600),
"two backoffs should stay within twice the unjittered 300ms, took {:?}",
start.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn retry_exhaustion_surfaces_last_error() {
let calls = Cell::new(0u32);
let p = policy(3, 100, 2000);
let r: Result<u32> = retry_with(Some(p), || {
calls.set(calls.get() + 1);
async { Err(Error::TimedOut) }
})
.await;
assert!(matches!(r, Err(Error::TimedOut)));
assert_eq!(calls.get(), 3, "should have exhausted all three attempts");
}
#[tokio::test(start_paused = true)]
async fn non_retriable_short_circuits_without_sleep() {
let calls = Cell::new(0u32);
let p = policy(5, 100, 2000);
let start = tokio::time::Instant::now();
let r: Result<u32> = retry_with(Some(p), || {
calls.set(calls.get() + 1);
async { Err(server_error()) }
})
.await;
assert!(matches!(r, Err(Error::ServerException { code: 60, .. })));
assert_eq!(calls.get(), 1, "non-retriable must not retry");
assert_eq!(start.elapsed(), Duration::ZERO, "must not sleep");
}
#[tokio::test(start_paused = true)]
async fn none_policy_single_pass_no_sleep() {
let calls = Cell::new(0u32);
let start = tokio::time::Instant::now();
let r: Result<u32> = retry_with(None, || {
calls.set(calls.get() + 1);
async { Err(Error::TimedOut) }
})
.await;
assert!(matches!(r, Err(Error::TimedOut)));
assert_eq!(calls.get(), 1, "None must run exactly one pass");
assert_eq!(start.elapsed(), Duration::ZERO, "None must not sleep");
}
#[tokio::test(start_paused = true)]
async fn single_attempt_policy_matches_none() {
let calls = Cell::new(0u32);
let start = tokio::time::Instant::now();
let r: Result<u32> = retry_with(Some(policy(1, 100, 2000)), || {
calls.set(calls.get() + 1);
async { Err(Error::TimedOut) }
})
.await;
assert!(matches!(r, Err(Error::TimedOut)));
assert_eq!(calls.get(), 1);
assert_eq!(start.elapsed(), Duration::ZERO);
}
#[tokio::test(start_paused = true)]
async fn none_policy_success_first_try() {
let r: Result<u32> = retry_with(None, || async { Ok(7) }).await;
assert_eq!(r.unwrap(), 7);
}
#[tokio::test]
async fn run_with_retry_reacquires_after_a_transient_failure() {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::net::TcpListener;
use crate::tcp::handshake::HandshakeConfig;
use crate::tcp::mock::serve_one_handshake;
use crate::tcp::pool::{ConnectKindConfig, PoolConfig, build_pool};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let server = tokio::spawn(async move {
for _ in 0..2 {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
serve_one_handshake(&mut sock).await;
tokio::time::sleep(Duration::from_millis(400)).await;
}
});
let pool = Arc::new(
build_pool(
vec![addr],
ConnectKindConfig::Plain,
HandshakeConfig::default(),
PoolConfig {
max_size: 1,
..PoolConfig::default()
},
#[cfg(feature = "tls")]
crate::tcp::pool::TcpTls::NotConfigured,
)
.expect("test pool builds"),
);
let attempts = AtomicUsize::new(0);
let policy = RetryPolicy {
max_attempts: 2,
initial_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(1),
};
let result: Result<()> = run_with_retry(&pool, Some(policy), |conn| {
let n = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if n == 0 {
conn.poison();
return Err(Error::Transient("tcp pool: simulated".to_string()));
}
assert!(
conn.is_alive(),
"the retry must run on a freshly created connection"
);
Ok(())
}
})
.await;
result.expect("the second attempt succeeds on a fresh connection");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
let _ = server.await;
}
}