use std::time::Duration;
use async_trait::async_trait;
use nym_bandwidth_controller::error::FetcherErrorKind;
use nym_bandwidth_controller::{
CredentialFetcher, CredentialFetcherError, CredentialPublicDataFetcher, FetcherError,
NymCredential, TicketType,
};
use nym_credentials::{
AggregatedCoinIndicesSignatures, AggregatedExpirationDateSignatures, EpochVerificationKey,
};
use nym_ecash_time::Date;
use nym_validator_client::nym_api::EpochId;
pub const DEFAULT_PUBLIC_DATA_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Debug, thiserror::Error)]
#[error("ecash signers unresponsive: fetching {what} did not complete within {timeout:?}")]
pub struct SignerTimeout {
what: &'static str,
timeout: Duration,
}
impl FetcherError for SignerTimeout {
fn kind(&self) -> FetcherErrorKind {
FetcherErrorKind::Api
}
}
pub struct TimeoutFetcher<F> {
inner: F,
per_call: Duration,
}
impl<F> TimeoutFetcher<F> {
pub fn new(inner: F) -> Self {
Self::with_timeout(inner, DEFAULT_PUBLIC_DATA_TIMEOUT)
}
pub fn with_timeout(inner: F, per_call: Duration) -> Self {
TimeoutFetcher { inner, per_call }
}
async fn bounded<T>(
&self,
what: &'static str,
fut: impl std::future::Future<Output = Result<T, CredentialFetcherError>>,
) -> Result<T, CredentialFetcherError> {
match tokio::time::timeout(self.per_call, fut).await {
Ok(res) => res,
Err(_elapsed) => Err(SignerTimeout {
what,
timeout: self.per_call,
}
.into()),
}
}
}
#[async_trait]
impl<F: CredentialPublicDataFetcher> CredentialPublicDataFetcher for TimeoutFetcher<F> {
async fn fetch_master_verification_key(
&self,
epoch_id: EpochId,
) -> Result<EpochVerificationKey, CredentialFetcherError> {
self.bounded(
"the master verification key",
self.inner.fetch_master_verification_key(epoch_id),
)
.await
}
async fn fetch_coin_index_signatures(
&self,
epoch_id: EpochId,
) -> Result<AggregatedCoinIndicesSignatures, CredentialFetcherError> {
self.bounded(
"coin-index signatures",
self.inner.fetch_coin_index_signatures(epoch_id),
)
.await
}
async fn fetch_expiration_date_signatures(
&self,
expiration_date: Date,
epoch_id: EpochId,
) -> Result<AggregatedExpirationDateSignatures, CredentialFetcherError> {
self.bounded(
"expiration-date signatures",
self.inner
.fetch_expiration_date_signatures(expiration_date, epoch_id),
)
.await
}
}
#[async_trait]
impl<F: CredentialFetcher> CredentialFetcher for TimeoutFetcher<F> {
async fn fetch_ticketbooks(
&self,
ticketbook_type: TicketType,
) -> Result<Vec<NymCredential>, CredentialFetcherError> {
self.inner.fetch_ticketbooks(ticketbook_type).await
}
async fn cleanup(&self) {
self.inner.cleanup().await
}
async fn reset(self) -> Result<(), CredentialFetcherError> {
self.inner.reset().await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Copy)]
enum Mode {
Hang,
ErrAfter(Duration),
Err,
}
#[derive(Debug, thiserror::Error)]
#[error("stub inner error")]
struct StubError;
impl FetcherError for StubError {
fn kind(&self) -> FetcherErrorKind {
FetcherErrorKind::Other
}
}
struct StubFetcher {
mode: Mode,
}
impl StubFetcher {
async fn act<T>(&self) -> Result<T, CredentialFetcherError> {
match self.mode {
Mode::Hang => std::future::pending().await,
Mode::ErrAfter(d) => {
tokio::time::sleep(d).await;
Err(StubError.into())
}
Mode::Err => Err(StubError.into()),
}
}
}
#[async_trait]
impl CredentialPublicDataFetcher for StubFetcher {
async fn fetch_master_verification_key(
&self,
_epoch_id: EpochId,
) -> Result<EpochVerificationKey, CredentialFetcherError> {
self.act().await
}
async fn fetch_coin_index_signatures(
&self,
_epoch_id: EpochId,
) -> Result<AggregatedCoinIndicesSignatures, CredentialFetcherError> {
self.act().await
}
async fn fetch_expiration_date_signatures(
&self,
_expiration_date: Date,
_epoch_id: EpochId,
) -> Result<AggregatedExpirationDateSignatures, CredentialFetcherError> {
self.act().await
}
}
#[async_trait]
impl CredentialFetcher for StubFetcher {
async fn fetch_ticketbooks(
&self,
_ticketbook_type: TicketType,
) -> Result<Vec<NymCredential>, CredentialFetcherError> {
self.act().await
}
async fn cleanup(&self) {}
async fn reset(self) -> Result<(), CredentialFetcherError> {
Ok(())
}
}
fn today() -> Date {
nym_ecash_time::ecash_today_date()
}
fn is_signer_timeout(err: &CredentialFetcherError) -> bool {
err.to_string().contains("ecash signers unresponsive")
}
fn is_stub_error(err: &CredentialFetcherError) -> bool {
err.to_string().contains("stub inner error")
}
const PER_CALL: Duration = Duration::from_secs(15);
fn fetcher(mode: Mode) -> TimeoutFetcher<StubFetcher> {
TimeoutFetcher::with_timeout(StubFetcher { mode }, PER_CALL)
}
#[tokio::test(start_paused = true)]
async fn hanging_public_data_fetch_times_out() {
let f = fetcher(Mode::Hang);
let err = f
.fetch_expiration_date_signatures(today(), 0)
.await
.expect_err("must not hang");
assert!(is_signer_timeout(&err), "got: {err}");
let err = f
.fetch_master_verification_key(0)
.await
.expect_err("must not hang");
assert!(is_signer_timeout(&err), "got: {err}");
let err = f
.fetch_coin_index_signatures(0)
.await
.expect_err("must not hang");
assert!(is_signer_timeout(&err), "got: {err}");
}
#[tokio::test(start_paused = true)]
async fn slow_fetch_under_threshold_completes() {
let f = fetcher(Mode::ErrAfter(PER_CALL - Duration::from_secs(1)));
let err = f
.fetch_expiration_date_signatures(today(), 0)
.await
.expect_err("stub errors after delay");
assert!(
is_stub_error(&err),
"inner outcome must pass through: {err}"
);
}
#[tokio::test(start_paused = true)]
async fn slow_fetch_over_threshold_times_out() {
let f = fetcher(Mode::ErrAfter(PER_CALL + Duration::from_secs(1)));
let err = f
.fetch_expiration_date_signatures(today(), 0)
.await
.expect_err("must time out");
assert!(is_signer_timeout(&err), "got: {err}");
}
#[tokio::test(start_paused = true)]
async fn immediate_inner_error_passes_through() {
let f = fetcher(Mode::Err);
let err = f
.fetch_expiration_date_signatures(today(), 0)
.await
.expect_err("stub errors");
assert!(is_stub_error(&err), "got: {err}");
}
#[tokio::test(start_paused = true)]
async fn fetch_ticketbooks_is_not_timed() {
let f = fetcher(Mode::Hang);
let probe = tokio::time::timeout(
Duration::from_secs(3600),
f.fetch_ticketbooks(TicketType::V1WireguardEntry),
)
.await;
assert!(
probe.is_err(),
"issuance must not be bounded by the public-data timeout"
);
}
}