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 {
match e {
VtaError::DidcommTransport(_)
| VtaError::TspTransport(_)
| VtaError::Network(_)
| VtaError::Server { .. }
| VtaError::Unavailable { .. } => true,
VtaError::RateLimited { retry_after, .. } => {
wait_until(*retry_after).is_none_or(|d| d <= MAX_RETRY_AFTER)
}
_ => false,
}
}
fn wait_until(at: Option<chrono::DateTime<chrono::Utc>>) -> Option<Duration> {
at.map(|at| (at - chrono::Utc::now()).to_std().unwrap_or(Duration::ZERO))
}
pub(crate) fn backoff_for(e: &VtaError, attempt: usize) -> Duration {
let hint = match e {
VtaError::Unavailable { retry_after } | VtaError::RateLimited { retry_after, .. } => {
*retry_after
}
_ => None,
};
if let Some(wait) = wait_until(hint) {
return wait.min(MAX_RETRY_AFTER);
}
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:?}"
);
}
fn rate_limited(retry_after: Option<chrono::DateTime<chrono::Utc>>) -> VtaError {
VtaError::RateLimited {
limited_by: crate::rate_limit::RateLimitSource::Vta,
retry_after,
limiter: None,
url: None,
}
}
#[test]
fn a_rate_limit_is_retried_when_its_wait_fits_the_cap() {
let soon = rate_limited(Some(chrono::Utc::now() + chrono::Duration::seconds(4)));
assert!(is_transient(&soon));
let d = backoff_for(&soon, 1);
assert!(
d <= Duration::from_secs(4) && d > Duration::from_secs(2),
"the server's wait must be honoured, not the 0.5s base: {d:?}"
);
let unhinted = rate_limited(None);
assert!(is_transient(&unhinted));
assert_eq!(backoff_for(&unhinted, 2), RETRY_BASE * 2);
}
#[test]
fn a_rate_limit_asking_for_longer_than_the_cap_surfaces_at_once() {
let far = rate_limited(Some(chrono::Utc::now() + chrono::Duration::minutes(5)));
assert!(
!is_transient(&far),
"sleeping the cap and re-sending would only be refused again"
);
}
#[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);
}
}