use std::time::Duration;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder;
use rand::RngExt;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use vta_config::{MediatorReadinessConfig, ReadinessTimeoutPolicy};
const NETWORK_RESOLVED_METHODS: &[&str] = &["did:webvh:", "did:web:"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateDecision {
Proceed,
Skip,
}
#[derive(Debug, Clone)]
pub struct ReadinessTimeout {
pub vta_did: String,
pub endpoint: Option<String>,
pub waited_secs: u64,
}
impl std::fmt::Display for ReadinessTimeout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"own DID {} did not resolve over the network after {}s",
self.vta_did, self.waited_secs
)?;
if let Some(endpoint) = &self.endpoint {
write!(f, " (expected its document at {endpoint})")?;
}
Ok(())
}
}
impl std::error::Error for ReadinessTimeout {}
pub fn needs_network_probe(vta_did: &str) -> bool {
NETWORK_RESOLVED_METHODS
.iter()
.any(|prefix| vta_did.starts_with(prefix))
}
pub fn self_did_endpoint_hint(vta_did: &str) -> Option<String> {
#[cfg(feature = "webvh")]
if vta_did.starts_with("did:webvh:") {
return match didwebvh_rs::url::WebVHURL::parse_did_url(vta_did) {
Ok(webvh) => match webvh.get_http_url(None) {
Ok(url) => Some(url.to_string()),
Err(e) => {
debug!(vta_did, error = %e, "self-readiness: cannot derive did.jsonl URL");
None
}
},
Err(e) => {
debug!(vta_did, error = %e, "self-readiness: cannot parse did:webvh DID");
None
}
};
}
let _ = vta_did;
None
}
pub async fn run_gate(
vta_did: &str,
cfg: &MediatorReadinessConfig,
resolver_url: Option<&str>,
shutdown: &CancellationToken,
) -> Result<GateDecision, ReadinessTimeout> {
let mut probe = SelfResolutionProbe::new(vta_did, resolver_url);
run_gate_with_probe(cfg, &mut probe, shutdown).await
}
pub async fn run_gate_with_probe(
cfg: &MediatorReadinessConfig,
probe: &mut SelfResolutionProbe,
shutdown: &CancellationToken,
) -> Result<GateDecision, ReadinessTimeout> {
let vta_did = probe.vta_did.clone();
let vta_did = vta_did.as_str();
if !cfg.enabled {
info!("mediator self-readiness gate disabled; connecting without waiting");
return Ok(GateDecision::Proceed);
}
if !needs_network_probe(vta_did) {
info!(
vta_did,
"self-readiness gate skipped: DID method resolves without a network fetch"
);
return Ok(GateDecision::Proceed);
}
let base = Duration::from_secs(cfg.retry_secs.max(1));
let cap = Duration::from_secs(cfg.backoff_cap_secs.max(cfg.retry_secs.max(1)));
let max_wait = Duration::from_secs(cfg.max_wait_secs);
let endpoint = self_did_endpoint_hint(vta_did);
info!(
vta_did,
endpoint = endpoint.as_deref().unwrap_or("<unknown>"),
base_secs = base.as_secs(),
backoff_cap_secs = cap.as_secs(),
max_wait_secs = cfg.max_wait_secs,
"waiting for own DID to resolve over the network before mediator connect \
(exponential backoff + full jitter)"
);
let did = vta_did.to_string();
let resolver_url = probe.resolver_url.clone();
let outcome = run_readiness_loop(base, cap, max_wait, shutdown, move || {
let did = did.clone();
let resolver_url = resolver_url.clone();
async move { self_did_resolves(&did, resolver_url.as_deref()).await }
})
.await;
match outcome {
LoopOutcome::Ready => {
probe.record_confirmed(tokio::time::Instant::now());
info!(
vta_did,
"own DID resolves over the network; proceeding to mediator connect"
);
Ok(GateDecision::Proceed)
}
LoopOutcome::Cancelled => {
info!("shutdown during self-readiness wait; not starting DIDComm");
Ok(GateDecision::Skip)
}
LoopOutcome::TimedOut => apply_timeout_policy(
cfg.on_timeout,
vta_did,
endpoint.as_deref(),
cfg.max_wait_secs,
),
}
}
fn apply_timeout_policy(
policy: ReadinessTimeoutPolicy,
vta_did: &str,
endpoint: Option<&str>,
waited_secs: u64,
) -> Result<GateDecision, ReadinessTimeout> {
match policy {
ReadinessTimeoutPolicy::Proceed => {
warn!(
vta_did,
waited_secs,
"self-readiness gate timed out; connecting to mediator anyway (best-effort)"
);
Ok(GateDecision::Proceed)
}
ReadinessTimeoutPolicy::Skip => {
warn!(
vta_did,
waited_secs,
"self-readiness gate timed out; skipping DIDComm startup this boot \
(/health stays live; a later restart reconnects)"
);
Ok(GateDecision::Skip)
}
ReadinessTimeoutPolicy::Fail => Err(ReadinessTimeout {
vta_did: vta_did.to_string(),
endpoint: endpoint.map(str::to_string),
waited_secs,
}),
}
}
async fn self_did_resolves(vta_did: &str, resolver_url: Option<&str>) -> bool {
let mut builder =
DIDCacheConfigBuilder::default().with_host_policy(vta_sdk::resolver::webvh_host_policy());
if let Some(url) = resolver_url {
builder = builder.with_network_mode(url);
}
let resolver = match DIDCacheClient::new(builder.build()).await {
Ok(r) => r,
Err(e) => {
debug!(error = %e, "self-readiness: could not build resolver for self-resolution probe");
return false;
}
};
let resolved = resolver.resolve(vta_did).await;
resolver.stop();
match resolved {
Ok(_) => true,
Err(e) => {
debug!(error = %e, "self-readiness: self-DID resolution not yet succeeding");
false
}
}
}
pub async fn self_did_network_resolvable(vta_did: &str, resolver_url: Option<&str>) -> bool {
if !needs_network_probe(vta_did) {
return true;
}
self_did_resolves(vta_did, resolver_url).await
}
pub const SELF_RESOLUTION_FRESH_FOR: Duration = Duration::from_secs(300);
#[derive(Debug, Clone)]
pub struct SelfResolutionProbe {
vta_did: String,
resolver_url: Option<String>,
fresh_for: Duration,
last_confirmed: Option<tokio::time::Instant>,
}
impl SelfResolutionProbe {
pub fn new(vta_did: &str, resolver_url: Option<&str>) -> Self {
Self {
vta_did: vta_did.to_string(),
resolver_url: resolver_url.map(str::to_string),
fresh_for: SELF_RESOLUTION_FRESH_FOR,
last_confirmed: None,
}
}
pub async fn is_resolvable(&mut self) -> bool {
if !needs_network_probe(&self.vta_did) {
return true;
}
let did = self.vta_did.clone();
let resolver_url = self.resolver_url.clone();
self.check_with(|| async move { self_did_resolves(&did, resolver_url.as_deref()).await })
.await
}
fn record_confirmed(&mut self, at: tokio::time::Instant) {
self.last_confirmed = Some(at);
}
async fn check_with<F, Fut>(&mut self, probe: F) -> bool
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = bool>,
{
let started = tokio::time::Instant::now();
if let Some(at) = self.last_confirmed
&& started.saturating_duration_since(at) < self.fresh_for
{
debug!(
vta_did = %self.vta_did,
confirmed_secs_ago = started.saturating_duration_since(at).as_secs(),
"self-readiness: own DID confirmed resolvable recently; not re-fetching"
);
return true;
}
if probe().await {
self.record_confirmed(started);
true
} else {
self.last_confirmed = None;
false
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LoopOutcome {
Ready,
TimedOut,
Cancelled,
}
async fn run_readiness_loop<F, Fut>(
base: Duration,
cap: Duration,
max_wait: Duration,
shutdown: &CancellationToken,
mut probe: F,
) -> LoopOutcome
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = bool>,
{
let deadline = tokio::time::Instant::now() + max_wait;
let mut attempt: u32 = 0;
loop {
if probe().await {
return LoopOutcome::Ready;
}
if shutdown.is_cancelled() {
return LoopOutcome::Cancelled;
}
let now = tokio::time::Instant::now();
if now >= deadline {
return LoopOutcome::TimedOut;
}
let ceiling = backoff_ceiling(base, cap, attempt);
let sleep_for = jittered_backoff(ceiling).min(deadline - now);
debug!(
attempt = attempt + 1,
ceiling_secs = ceiling.as_secs_f64(),
sleep_secs = sleep_for.as_secs_f64(),
"self-readiness probe not ready; backing off before retry"
);
tokio::select! {
_ = shutdown.cancelled() => return LoopOutcome::Cancelled,
_ = tokio::time::sleep(sleep_for) => {}
}
attempt = attempt.saturating_add(1);
}
}
pub(crate) const MIN_HEALTHY_SESSION: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub(crate) struct ReconnectPolicy {
base: Duration,
cap: Duration,
horizon: Option<Duration>,
reconnect: bool,
}
impl ReconnectPolicy {
pub(crate) fn from_config(cfg: &MediatorReadinessConfig) -> Self {
let base_secs = cfg.retry_secs.max(1);
Self {
base: Duration::from_secs(base_secs),
cap: Duration::from_secs(cfg.reconnect_backoff_cap_secs.max(base_secs)),
horizon: (cfg.reconnect_max_elapsed_secs > 0)
.then(|| Duration::from_secs(cfg.reconnect_max_elapsed_secs)),
reconnect: cfg.reconnect,
}
}
pub(crate) fn next_backoff(&self, attempt: u32, failing_for: Duration) -> Option<Duration> {
if !self.reconnect {
return None;
}
if let Some(horizon) = self.horizon
&& failing_for >= horizon
{
return None;
}
Some(jittered_backoff(backoff_ceiling(
self.base, self.cap, attempt,
)))
}
pub(crate) fn ceiling_for(&self, attempt: u32) -> Duration {
backoff_ceiling(self.base, self.cap, attempt)
}
pub(crate) fn session_was_healthy(&self, session: Duration) -> bool {
session >= MIN_HEALTHY_SESSION
}
}
fn backoff_ceiling(base: Duration, cap: Duration, attempt: u32) -> Duration {
let factor = 1u64.checked_shl(attempt).unwrap_or(u64::MAX); let ceil_ms = base
.as_millis()
.saturating_mul(factor as u128)
.min(cap.as_millis());
Duration::from_millis(ceil_ms as u64)
}
fn jittered_backoff(ceiling: Duration) -> Duration {
let max_ms = ceiling.as_millis().min(u128::from(u64::MAX)) as u64;
if max_ms == 0 {
return Duration::ZERO;
}
Duration::from_millis(rand::rng().random_range(0..=max_ms))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
#[test]
fn did_key_needs_no_network_probe() {
assert!(!needs_network_probe("did:key:z6MkExampleKeyValue"));
}
#[test]
fn webvh_and_web_need_a_network_probe() {
assert!(needs_network_probe("did:webvh:QmScid:example.com:agent"));
assert!(needs_network_probe("did:web:example.com:agent"));
}
#[test]
fn unknown_method_is_not_gated() {
assert!(!needs_network_probe("did:peer:2zSomething"));
assert!(!needs_network_probe("did:example:whatever"));
}
#[cfg(feature = "webvh")]
#[test]
fn webvh_endpoint_hint_is_derived_for_diagnostics() {
let did = "did:webvh:QmExampleScid:example.com:budget-engine";
assert_eq!(
self_did_endpoint_hint(did).as_deref(),
Some("https://example.com/budget-engine/did.jsonl")
);
}
#[test]
fn no_endpoint_hint_for_did_key() {
assert!(self_did_endpoint_hint("did:key:z6MkExampleKeyValue").is_none());
}
#[test]
fn timeout_policy_maps_to_decision() {
let did = "did:webvh:QmScid:example.com:agent";
assert_eq!(
apply_timeout_policy(ReadinessTimeoutPolicy::Skip, did, None, 30).unwrap(),
GateDecision::Skip
);
assert_eq!(
apply_timeout_policy(ReadinessTimeoutPolicy::Proceed, did, None, 30).unwrap(),
GateDecision::Proceed
);
assert!(apply_timeout_policy(ReadinessTimeoutPolicy::Fail, did, None, 30).is_err());
}
#[test]
fn timeout_error_names_the_endpoint_when_known() {
let err = apply_timeout_policy(
ReadinessTimeoutPolicy::Fail,
"did:webvh:QmScid:example.com:agent",
Some("https://example.com/agent/did.jsonl"),
300,
)
.expect_err("fail policy errors");
let msg = err.to_string();
assert!(msg.contains("did:webvh:QmScid:example.com:agent"), "{msg}");
assert!(msg.contains("https://example.com/agent/did.jsonl"), "{msg}");
assert!(msg.contains("300s"), "{msg}");
}
#[tokio::test]
async fn loop_times_out_when_never_ready() {
let calls = Cell::new(0u64);
let outcome = run_readiness_loop(
Duration::from_millis(2),
Duration::from_millis(8),
Duration::from_millis(20),
&CancellationToken::new(),
|| {
calls.set(calls.get() + 1);
async { false }
},
)
.await;
assert_eq!(outcome, LoopOutcome::TimedOut);
assert!(calls.get() >= 1, "at least one probe attempt must run");
}
#[tokio::test]
async fn loop_succeeds_after_a_few_attempts() {
let calls = Cell::new(0u64);
let outcome = run_readiness_loop(
Duration::from_millis(1),
Duration::from_millis(4),
Duration::from_secs(5),
&CancellationToken::new(),
|| {
let n = calls.get() + 1;
calls.set(n);
async move { n >= 3 }
},
)
.await;
assert_eq!(outcome, LoopOutcome::Ready);
assert_eq!(calls.get(), 3, "should stop probing once ready");
}
#[tokio::test]
async fn loop_abandons_the_wait_on_shutdown() {
let shutdown = CancellationToken::new();
shutdown.cancel();
let calls = Cell::new(0u64);
let started = tokio::time::Instant::now();
let outcome = run_readiness_loop(
Duration::from_secs(5),
Duration::from_secs(30),
Duration::from_secs(60),
&shutdown,
|| {
calls.set(calls.get() + 1);
async { false }
},
)
.await;
assert_eq!(outcome, LoopOutcome::Cancelled);
assert_eq!(calls.get(), 1, "one probe runs, then cancellation wins");
assert!(
started.elapsed() < Duration::from_secs(1),
"cancellation must not wait out the horizon"
);
}
async fn drive_probe(probe: &mut SelfResolutionProbe, results: &[bool]) -> (Vec<bool>, usize) {
let calls = Cell::new(0usize);
let mut answers = Vec::with_capacity(results.len());
for &result in results {
let answer = probe
.check_with(|| {
calls.set(calls.get() + 1);
async move { result }
})
.await;
answers.push(answer);
}
(answers, calls.get())
}
const WEBVH_DID: &str = "did:webvh:QmScid:example.com:agent";
#[tokio::test]
async fn consecutive_successful_checks_fetch_once_within_the_window() {
let mut probe = SelfResolutionProbe::new(WEBVH_DID, None);
let (answers, calls) = drive_probe(&mut probe, &[true, true, true, true]).await;
assert_eq!(answers, vec![true; 4]);
assert_eq!(calls, 1, "a recent success must answer later checks");
}
#[tokio::test]
async fn a_failed_check_is_not_remembered() {
let mut probe = SelfResolutionProbe::new(WEBVH_DID, None);
let (answers, calls) = drive_probe(&mut probe, &[false, false, true, true]).await;
assert_eq!(answers, vec![false, false, true, true]);
assert_eq!(
calls, 3,
"both failures and the first success resolve for real"
);
}
#[tokio::test]
async fn a_success_expires_after_the_window() {
let mut probe = SelfResolutionProbe::new(WEBVH_DID, None);
probe.fresh_for = Duration::from_millis(20);
let (_, first) = drive_probe(&mut probe, &[true]).await;
tokio::time::sleep(Duration::from_millis(40)).await;
let (answers, second) = drive_probe(&mut probe, &[false, true]).await;
assert_eq!((first, second), (1, 2));
assert_eq!(answers, vec![false, true]);
}
#[tokio::test]
async fn a_gate_pass_answers_the_first_reconnect_check() {
let mut probe = SelfResolutionProbe::new(WEBVH_DID, None);
probe.record_confirmed(tokio::time::Instant::now());
let (answers, calls) = drive_probe(&mut probe, &[false]).await;
assert_eq!(answers, vec![true]);
assert_eq!(
calls, 0,
"the gate's pass must not be re-fetched straight away"
);
}
#[test]
fn freshness_window_does_not_outlive_the_mediator_resolver_cache() {
assert_eq!(SELF_RESOLUTION_FRESH_FOR, Duration::from_secs(300));
}
#[tokio::test]
async fn did_key_probe_is_ready_without_resolving() {
let mut probe = SelfResolutionProbe::new("did:key:z6MkExampleKeyValue", None);
assert!(probe.is_resolvable().await);
assert!(probe.last_confirmed.is_none());
}
#[test]
fn backoff_ceiling_doubles_then_caps() {
let base = Duration::from_secs(5);
let cap = Duration::from_secs(30);
assert_eq!(backoff_ceiling(base, cap, 0), Duration::from_secs(5)); assert_eq!(backoff_ceiling(base, cap, 1), Duration::from_secs(10)); assert_eq!(backoff_ceiling(base, cap, 2), Duration::from_secs(20)); assert_eq!(backoff_ceiling(base, cap, 3), Duration::from_secs(30)); assert_eq!(backoff_ceiling(base, cap, 10), Duration::from_secs(30)); assert_eq!(
backoff_ceiling(base, cap, u32::MAX),
Duration::from_secs(30)
);
}
#[test]
fn jitter_stays_within_ceiling() {
let ceiling = Duration::from_secs(30);
for _ in 0..1000 {
assert!(jittered_backoff(ceiling) <= ceiling);
}
assert_eq!(jittered_backoff(Duration::ZERO), Duration::ZERO);
}
#[test]
fn policy_retries_forever_by_default() {
let policy = ReconnectPolicy::from_config(&MediatorReadinessConfig::default());
assert!(policy.next_backoff(0, Duration::from_secs(0)).is_some());
assert!(
policy
.next_backoff(99, Duration::from_secs(86_400 * 365))
.is_some(),
"0 horizon must mean never give up"
);
}
#[test]
fn policy_with_reconnect_disabled_never_retries() {
let policy = ReconnectPolicy::from_config(&MediatorReadinessConfig {
reconnect: false,
..Default::default()
});
assert!(
policy.next_backoff(0, Duration::ZERO).is_none(),
"reconnect = false must preserve legacy single-shot behaviour"
);
}
#[test]
fn policy_gives_up_once_the_horizon_is_exhausted() {
let policy = ReconnectPolicy::from_config(&MediatorReadinessConfig {
reconnect_max_elapsed_secs: 120,
..Default::default()
});
assert!(policy.next_backoff(3, Duration::from_secs(119)).is_some());
assert!(
policy.next_backoff(3, Duration::from_secs(120)).is_none(),
"at the horizon exactly, stop"
);
assert!(policy.next_backoff(3, Duration::from_secs(600)).is_none());
}
#[test]
fn policy_backoff_is_bounded_by_the_reconnect_cap() {
let policy = ReconnectPolicy::from_config(&MediatorReadinessConfig {
retry_secs: 5,
reconnect_backoff_cap_secs: 60,
..Default::default()
});
assert_eq!(policy.ceiling_for(0), Duration::from_secs(5));
assert_eq!(policy.ceiling_for(4), Duration::from_secs(60)); for attempt in 0..40 {
let waited = policy
.next_backoff(attempt, Duration::ZERO)
.expect("no horizon set");
assert!(
waited <= Duration::from_secs(60),
"attempt {attempt} slept {waited:?}, past the cap"
);
}
}
#[test]
fn policy_cap_below_base_still_grows() {
let policy = ReconnectPolicy::from_config(&MediatorReadinessConfig {
retry_secs: 30,
reconnect_backoff_cap_secs: 5,
..Default::default()
});
assert_eq!(policy.ceiling_for(0), Duration::from_secs(30));
}
#[test]
fn only_a_long_enough_session_counts_as_healthy() {
let policy = ReconnectPolicy::from_config(&MediatorReadinessConfig::default());
assert!(!policy.session_was_healthy(Duration::from_secs(1)));
assert!(!policy.session_was_healthy(MIN_HEALTHY_SESSION - Duration::from_secs(1)));
assert!(policy.session_was_healthy(MIN_HEALTHY_SESSION));
assert!(policy.session_was_healthy(Duration::from_secs(3600)));
}
#[tokio::test]
async fn disabled_gate_proceeds_without_probing() {
let cfg = MediatorReadinessConfig {
enabled: false,
..Default::default()
};
assert_eq!(
run_gate(
"did:webvh:QmScid:example.com:agent",
&cfg,
None,
&CancellationToken::new()
)
.await
.unwrap(),
GateDecision::Proceed
);
}
#[tokio::test]
async fn did_key_skips_gate_and_proceeds() {
let cfg = MediatorReadinessConfig::default();
assert_eq!(
run_gate(
"did:key:z6MkExampleKeyValue",
&cfg,
None,
&CancellationToken::new()
)
.await
.unwrap(),
GateDecision::Proceed
);
}
}