use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use decdn_client::provider::{is_permanent_contract_error, is_permanent_rpc_error};
use decdn_common::redact::sanitize_err_chain;
use tokio::time::Instant;
use tracing::warn;
use super::{WATCHER_INITIAL_BACKOFF, WATCHER_MAX_BACKOFF};
use crate::metrics::Metrics;
pub(crate) const BOOT_CHAIN_RETRY_BUDGET: Duration = Duration::from_mins(10);
#[derive(Debug)]
pub(crate) struct BootFault(pub(crate) String);
impl std::fmt::Display for BootFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for BootFault {}
#[derive(Debug)]
pub struct BootRetry {
started: Instant,
deadline: Instant,
budget: Duration,
metrics: Arc<Metrics>,
}
impl BootRetry {
#[must_use]
pub fn new(budget: Duration, metrics: Arc<Metrics>) -> Self {
let started = Instant::now();
let deadline = started
.checked_add(budget)
.unwrap_or_else(|| started + Duration::from_secs(u64::from(u32::MAX)));
Self {
started,
deadline,
budget,
metrics,
}
}
#[must_use]
pub fn single_attempt(metrics: Arc<Metrics>) -> Self {
Self::new(Duration::ZERO, metrics)
}
#[must_use]
pub(crate) fn capped(&self, cap: Duration) -> Self {
let now = Instant::now();
let deadline = now
.checked_add(cap)
.map_or(self.deadline, |d| d.min(self.deadline));
Self {
started: self.started,
deadline,
budget: deadline.saturating_duration_since(now),
metrics: Arc::clone(&self.metrics),
}
}
pub(crate) async fn run<T, F, Fut>(&self, what: &'static str, mut attempt: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T>>,
{
let start = Instant::now();
let mut backoff = WATCHER_INITIAL_BACKOFF;
let mut attempts: u32 = 0;
loop {
attempts = attempts.saturating_add(1);
let err = match attempt().await {
Ok(v) => return Ok(v),
Err(err) => err,
};
if is_permanent_boot_error(&err) {
return Err(err.context(format!("{what}: deterministic failure, not retried")));
}
if Instant::now() + backoff > self.deadline {
let elapsed = start.elapsed();
let boot_elapsed = self.started.elapsed();
let budget = self.budget;
return Err(err.context(format!(
"{what}: gave up after {attempts} attempts in {elapsed:?}; the shared \
boot chain-read budget of {budget:?} is spent ({boot_elapsed:?} since \
the boot reads began)"
)));
}
warn!(
read = what,
attempt = attempts,
backoff_secs = backoff.as_secs(),
error = %sanitize_err_chain(&err),
"boot chain read failed; retrying after backoff"
);
self.metrics.chain_boot_read_retried();
tokio::time::sleep(backoff).await;
backoff = backoff.saturating_mul(2).min(WATCHER_MAX_BACKOFF);
}
}
}
fn is_permanent_boot_error(err: &anyhow::Error) -> bool {
err.chain()
.find_map(|cause| {
if cause.is::<BootFault>() {
Some(true)
} else if let Some(e) = cause.downcast_ref::<alloy::contract::Error>() {
Some(is_permanent_contract_error(e))
} else {
cause
.downcast_ref::<alloy::transports::TransportError>()
.map(is_permanent_rpc_error)
}
})
.unwrap_or(false)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests;