use std::time::Duration;
use crate::error::VtaError;
pub const MAX_ATTEMPTS: usize = 3;
pub const RETRY_BASE: Duration = Duration::from_millis(500);
pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
tokio::task_local! {
pub(crate) static IDEMPOTENCY_KEY: String;
}
pub fn current_key() -> Option<String> {
IDEMPOTENCY_KEY.try_with(|k| k.clone()).ok()
}
pub fn new_key() -> String {
format!("urn:uuid:{}", uuid::Uuid::new_v4())
}
pub fn is_transient(e: &VtaError) -> bool {
matches!(
e,
VtaError::DidcommTransport(_)
| VtaError::TspTransport(_)
| VtaError::Network(_)
| VtaError::Server { .. }
| VtaError::Unavailable { .. }
)
}
pub(crate) fn backoff_for(e: &VtaError, attempt: usize) -> Duration {
if let VtaError::Unavailable {
retry_after: Some(at),
} = e
{
let delta = *at - chrono::Utc::now();
if let Ok(d) = delta.to_std() {
return d.min(MAX_RETRY_AFTER);
}
return Duration::ZERO;
}
RETRY_BASE * (1 << (attempt.saturating_sub(1)) as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_unique() {
assert_ne!(new_key(), new_key());
}
#[test]
fn transient_faults_are_retried_and_deterministic_ones_are_not() {
assert!(is_transient(&VtaError::DidcommTransport("stale".into())));
assert!(is_transient(&VtaError::Server {
status: 502,
body: String::new()
}));
assert!(is_transient(&VtaError::Unavailable { retry_after: None }));
assert!(!is_transient(&VtaError::Validation("bad".into())));
assert!(!is_transient(&VtaError::Conflict("exists".into())));
assert!(!is_transient(&VtaError::NotFound("gone".into())));
assert!(!is_transient(&VtaError::Auth("expired".into())));
assert!(!is_transient(&VtaError::Gone("consumed".into())));
}
#[test]
fn backoff_doubles_without_a_server_hint() {
let e = VtaError::DidcommTransport("x".into());
assert_eq!(backoff_for(&e, 1), RETRY_BASE);
assert_eq!(backoff_for(&e, 2), RETRY_BASE * 2);
}
#[test]
fn a_server_hint_is_honoured_but_capped() {
let far = VtaError::Unavailable {
retry_after: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
};
assert_eq!(
backoff_for(&far, 1),
MAX_RETRY_AFTER,
"an unbounded server-chosen wait is a stall the server can trigger"
);
let soon = VtaError::Unavailable {
retry_after: Some(chrono::Utc::now() + chrono::Duration::seconds(2)),
};
let d = backoff_for(&soon, 1);
assert!(
d <= Duration::from_secs(2) && d > Duration::from_millis(500),
"{d:?}"
);
}
#[test]
fn a_stale_hint_retries_promptly_rather_than_waiting() {
let past = VtaError::Unavailable {
retry_after: Some(chrono::Utc::now() - chrono::Duration::seconds(30)),
};
assert_eq!(backoff_for(&past, 1), Duration::ZERO);
}
#[tokio::test]
async fn a_key_is_visible_only_inside_its_scope() {
assert_eq!(current_key(), None);
let k = new_key();
IDEMPOTENCY_KEY
.scope(k.clone(), async {
assert_eq!(current_key().as_deref(), Some(k.as_str()));
})
.await;
assert_eq!(current_key(), None);
}
}