use azure_core::{sleep, time::Duration};
use rand::random;
use std::{fmt::Debug, pin::Pin};
use tracing::{debug, info, warn};
pub(crate) type RecoveryOperation<C, E> = fn(
C,
ErrorRecoveryAction,
) -> Pin<
Box<dyn std::future::Future<Output = std::result::Result<(), E>> + Send + 'static>,
>;
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) enum ErrorRecoveryAction {
RetryAction,
ReconnectConnection,
ReconnectSession,
ReconnectLink,
ReturnError,
}
#[derive(Debug, Clone)]
pub struct RetryOptions {
pub initial_delay: Duration,
pub max_delay: Duration,
pub max_total_elapsed: Duration,
pub max_retries: u32,
}
impl Default for RetryOptions {
fn default() -> Self {
Self {
initial_delay: Duration::milliseconds(200),
max_delay: Duration::seconds(30),
max_retries: 8,
max_total_elapsed: Duration::seconds(60),
}
}
}
pub(crate) async fn recover_with_backoff<F, Fut, T, E, C>(
operation: F,
options: &RetryOptions,
categorize_error: fn(&E) -> ErrorRecoveryAction,
recover_operation: Option<RecoveryOperation<C, E>>,
context: Option<C>,
) -> std::result::Result<T, E>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = std::result::Result<T, E>>,
E: Debug + std::fmt::Display,
C: Clone,
{
let mut current_retry = 0u32;
let mut current_delay = options.initial_delay;
let start_time = std::time::Instant::now();
loop {
match operation().await {
Ok(result) => {
if current_retry > 0 {
info!("Operation succeeded after {} retries", current_retry);
}
return Ok(result);
}
Err(err) => {
let time_since_start = start_time.elapsed();
debug!(
err = %err,
current_retry,
"Operation failed, checking for retry."
);
if current_retry >= options.max_retries
|| time_since_start >= options.max_total_elapsed
{
warn!(
err = ?err,
max_retries = options.max_retries,
elapsed = ?time_since_start,
"Maximum retries reached or time elapsed, returning error."
);
return Err(err);
}
let error_category = categorize_error(&err);
match error_category {
ErrorRecoveryAction::RetryAction => {
let sleep_ms = options.initial_delay.whole_milliseconds() as u64
* 2u64.pow(current_retry)
+ u64::from(random::<u8>());
let sleep_ms = sleep_ms.min(
options
.max_delay
.whole_milliseconds()
.try_into()
.unwrap_or(u64::MAX),
);
let sleep_duration = Duration::milliseconds(sleep_ms as i64);
debug!(
err = ?err,
backoff = ?sleep_duration,
retry = current_retry + 1,
max_retries = options.max_retries,
"Operation failed, retrying after backoff."
);
sleep(sleep_duration).await;
let next_delay = current_delay.saturating_mul(2);
current_delay = std::cmp::min(next_delay, options.max_delay);
}
ErrorRecoveryAction::ReturnError => {
warn!(err = ?err, "Error is not retryable, returning.");
return Err(err);
}
_ => {
warn!(
error_category = ?error_category,
err = ?err,
"Error requires recovery, attempting recovery action."
);
if let (Some(recover_operation), Some(context)) =
(recover_operation, context.clone())
{
match recover_operation(context, error_category.clone()).await {
Ok(()) => {
info!(
error_category = ?error_category,
"Recovery action succeeded."
);
}
Err(recovery_err) => {
warn!(
error_category = ?error_category,
err = ?recovery_err,
"Recovery action failed."
);
return Err(recovery_err);
}
}
} else {
return Err(err);
}
}
}
current_retry += 1;
}
}
}
}
pub(crate) async fn recover_azure_operation<F, Fut, T, C, E>(
operation: F,
options: &RetryOptions,
categorize_error: fn(&E) -> ErrorRecoveryAction,
recover_operation: Option<RecoveryOperation<C, E>>,
context: Option<C>,
) -> std::result::Result<T, E>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = std::result::Result<T, E>>,
E: Debug + std::error::Error,
C: Clone,
{
recover_with_backoff(
operation,
options,
categorize_error,
recover_operation,
context,
)
.await
}
#[cfg(test)]
mod tests {
use crate::EventHubsError;
use super::*;
use azure_core_test::{recorded, TestContext};
use std::{
result,
sync::atomic::{AtomicUsize, Ordering},
};
use tracing::info;
#[recorded::test]
async fn test_retry_success_on_first_attempt(_ctx: TestContext) -> Result<(), EventHubsError> {
let result = recover_with_backoff(
|| async { Ok::<_, String>("success") },
&RetryOptions::default(),
|_| ErrorRecoveryAction::RetryAction,
None,
None::<()>,
)
.await;
assert_eq!(result.unwrap(), "success");
Ok(())
}
#[recorded::test]
async fn test_retry_success_after_retries(_ctx: TestContext) -> Result<(), EventHubsError> {
let attempts = AtomicUsize::new(0);
let result = recover_with_backoff(
|| async {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
if attempt < 2 {
Err(format!("Failed attempt {}", attempt))
} else {
Ok(format!("Success on attempt {}", attempt))
}
},
&RetryOptions::default(),
|_| ErrorRecoveryAction::RetryAction,
None,
None::<()>,
)
.await;
assert_eq!(result.unwrap(), "Success on attempt 2");
assert_eq!(attempts.load(Ordering::SeqCst), 3);
Ok(())
}
#[recorded::test]
async fn test_retry_exhausted(_ctx: TestContext) -> Result<(), EventHubsError> {
let attempts = AtomicUsize::new(0);
let options = RetryOptions {
initial_delay: Duration::milliseconds(10),
max_delay: Duration::milliseconds(50),
max_retries: 2,
max_total_elapsed: Duration::seconds(10),
};
let result: result::Result<&str, String> = recover_with_backoff(
|| async {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
Err(format!("Failed attempt {}", attempt))
},
&options,
|_| ErrorRecoveryAction::RetryAction,
None,
None::<()>,
)
.await;
assert!(result.is_err());
assert_eq!(attempts.load(Ordering::SeqCst), 3); Ok(())
}
#[recorded::test]
async fn test_retry_with_is_retryable(_ctx: TestContext) -> Result<(), EventHubsError> {
let attempts = AtomicUsize::new(0);
let is_retryable = |err: &String| {
if err.contains("retry") {
ErrorRecoveryAction::RetryAction
} else {
ErrorRecoveryAction::ReturnError
}
};
let result = recover_with_backoff(
|| async {
info!("Attempting operation. {}", attempts.load(Ordering::SeqCst));
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
match attempt {
0 => Err(String::from("please retry")),
1 => Err(String::from("don't retry")),
2 => Err(String::from("I told you not to retry")),
_ => Ok("shouldn't get here"),
}
},
&RetryOptions {
initial_delay: Duration::milliseconds(10),
max_delay: Duration::milliseconds(50),
max_retries: 2,
max_total_elapsed: Duration::seconds(1),
},
is_retryable,
None,
None::<()>,
)
.await;
assert_eq!(result.unwrap_err(), "I told you not to retry");
assert_eq!(attempts.load(Ordering::SeqCst), 3);
Ok(())
}
}