Skip to main content

nym_sdk_session/
fetcher.rs

1// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4//! Signer-failure tolerance for the session's credential fetcher.
5//!
6//! The distributed ecash signers (and the nym-apis aggregating them) can be
7//! unresponsive for long stretches — observed on mainnet as an endpoint that
8//! accepts the connection and never responds. Without a bound, that hang
9//! propagates into the bandwidth controller's run loop: the freshly issued
10//! (paid-for) ticketbook is never persisted and provisioning blocks forever.
11//!
12//! [`TimeoutFetcher`] decorates any [`CredentialFetcher`] so the three
13//! read-only global-signing-data fetches (master verification key, coin-index
14//! signatures, expiration-date signatures) are bounded by a per-call timeout.
15//! A bounded failure is recoverable: the controller's ticketbook-store path is
16//! best-effort per step, so the ticketbook is persisted anyway and the missing
17//! signing data is fetched later (background reconciliation or spend time) —
18//! without a new deposit.
19//!
20//! The ticketbook-issuance call ([`CredentialFetcher::fetch_ticketbooks`]) is
21//! deliberately NOT timed: it deposits funds on-chain, and interrupting it is
22//! governed by the existing cancellation-safety + pending-request recovery
23//! guarantees, not by a fetch timeout.
24
25use std::time::Duration;
26
27use async_trait::async_trait;
28use nym_bandwidth_controller::error::FetcherErrorKind;
29use nym_bandwidth_controller::{
30    CredentialFetcher, CredentialFetcherError, CredentialPublicDataFetcher, FetcherError,
31    NymCredential, TicketType,
32};
33use nym_credentials::{
34    AggregatedCoinIndicesSignatures, AggregatedExpirationDateSignatures, EpochVerificationKey,
35};
36use nym_ecash_time::Date;
37use nym_validator_client::nym_api::EpochId;
38
39/// Default per-call bound for the read-only global-signing-data fetches. A
40/// healthy signer answers in well under a second; this is ~50x that, so it only
41/// ever fires on genuinely unresponsive infrastructure while turning an
42/// infinite hang into a bounded delay.
43pub const DEFAULT_PUBLIC_DATA_TIMEOUT: Duration = Duration::from_secs(15);
44
45/// A global-signing-data fetch exceeded its per-call bound. Surfaced through
46/// the controller as a fetch failure so readiness reporting names the cause.
47#[derive(Debug, thiserror::Error)]
48#[error("ecash signers unresponsive: fetching {what} did not complete within {timeout:?}")]
49pub struct SignerTimeout {
50    what: &'static str,
51    timeout: Duration,
52}
53
54impl FetcherError for SignerTimeout {
55    fn kind(&self) -> FetcherErrorKind {
56        // a nym-api / ecash query failure — transient from the caller's view
57        FetcherErrorKind::Api
58    }
59}
60
61/// Decorator over a [`CredentialFetcher`] bounding each read-only public-data
62/// fetch with a per-call timeout (see module docs for why issuance is exempt).
63pub struct TimeoutFetcher<F> {
64    inner: F,
65    per_call: Duration,
66}
67
68impl<F> TimeoutFetcher<F> {
69    /// Wrap `inner` with the [default per-call bound](DEFAULT_PUBLIC_DATA_TIMEOUT).
70    pub fn new(inner: F) -> Self {
71        Self::with_timeout(inner, DEFAULT_PUBLIC_DATA_TIMEOUT)
72    }
73
74    /// Wrap `inner` with a custom per-call bound.
75    pub fn with_timeout(inner: F, per_call: Duration) -> Self {
76        TimeoutFetcher { inner, per_call }
77    }
78
79    /// Run `fut` bounded by the per-call timeout, mapping elapse to [`SignerTimeout`].
80    async fn bounded<T>(
81        &self,
82        what: &'static str,
83        fut: impl std::future::Future<Output = Result<T, CredentialFetcherError>>,
84    ) -> Result<T, CredentialFetcherError> {
85        match tokio::time::timeout(self.per_call, fut).await {
86            Ok(res) => res,
87            Err(_elapsed) => Err(SignerTimeout {
88                what,
89                timeout: self.per_call,
90            }
91            .into()),
92        }
93    }
94}
95
96#[async_trait]
97impl<F: CredentialPublicDataFetcher> CredentialPublicDataFetcher for TimeoutFetcher<F> {
98    async fn fetch_master_verification_key(
99        &self,
100        epoch_id: EpochId,
101    ) -> Result<EpochVerificationKey, CredentialFetcherError> {
102        self.bounded(
103            "the master verification key",
104            self.inner.fetch_master_verification_key(epoch_id),
105        )
106        .await
107    }
108
109    async fn fetch_coin_index_signatures(
110        &self,
111        epoch_id: EpochId,
112    ) -> Result<AggregatedCoinIndicesSignatures, CredentialFetcherError> {
113        self.bounded(
114            "coin-index signatures",
115            self.inner.fetch_coin_index_signatures(epoch_id),
116        )
117        .await
118    }
119
120    async fn fetch_expiration_date_signatures(
121        &self,
122        expiration_date: Date,
123        epoch_id: EpochId,
124    ) -> Result<AggregatedExpirationDateSignatures, CredentialFetcherError> {
125        self.bounded(
126            "expiration-date signatures",
127            self.inner
128                .fetch_expiration_date_signatures(expiration_date, epoch_id),
129        )
130        .await
131    }
132}
133
134#[async_trait]
135impl<F: CredentialFetcher> CredentialFetcher for TimeoutFetcher<F> {
136    /// NOT timed — issuance deposits funds on-chain; see module docs.
137    async fn fetch_ticketbooks(
138        &self,
139        ticketbook_type: TicketType,
140    ) -> Result<Vec<NymCredential>, CredentialFetcherError> {
141        self.inner.fetch_ticketbooks(ticketbook_type).await
142    }
143
144    async fn cleanup(&self) {
145        self.inner.cleanup().await
146    }
147
148    async fn reset(self) -> Result<(), CredentialFetcherError> {
149        self.inner.reset().await
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    /// How a [`StubFetcher`] call behaves. `Ok` outcomes are irrelevant to the
158    /// timeout semantics (the decorator wraps the future, not its value), so
159    /// completion is proven by a distinguishable *inner* error surfacing —
160    /// avoiding the need to fabricate ecash values here.
161    #[derive(Clone, Copy)]
162    enum Mode {
163        /// Accept the call and never complete (an unresponsive signer).
164        Hang,
165        /// Complete with the inner error after this long.
166        ErrAfter(Duration),
167        /// Complete with the inner error immediately.
168        Err,
169    }
170
171    #[derive(Debug, thiserror::Error)]
172    #[error("stub inner error")]
173    struct StubError;
174
175    impl FetcherError for StubError {
176        fn kind(&self) -> FetcherErrorKind {
177            FetcherErrorKind::Other
178        }
179    }
180
181    struct StubFetcher {
182        mode: Mode,
183    }
184
185    impl StubFetcher {
186        async fn act<T>(&self) -> Result<T, CredentialFetcherError> {
187            match self.mode {
188                Mode::Hang => std::future::pending().await,
189                Mode::ErrAfter(d) => {
190                    tokio::time::sleep(d).await;
191                    Err(StubError.into())
192                }
193                Mode::Err => Err(StubError.into()),
194            }
195        }
196    }
197
198    #[async_trait]
199    impl CredentialPublicDataFetcher for StubFetcher {
200        async fn fetch_master_verification_key(
201            &self,
202            _epoch_id: EpochId,
203        ) -> Result<EpochVerificationKey, CredentialFetcherError> {
204            self.act().await
205        }
206
207        async fn fetch_coin_index_signatures(
208            &self,
209            _epoch_id: EpochId,
210        ) -> Result<AggregatedCoinIndicesSignatures, CredentialFetcherError> {
211            self.act().await
212        }
213
214        async fn fetch_expiration_date_signatures(
215            &self,
216            _expiration_date: Date,
217            _epoch_id: EpochId,
218        ) -> Result<AggregatedExpirationDateSignatures, CredentialFetcherError> {
219            self.act().await
220        }
221    }
222
223    #[async_trait]
224    impl CredentialFetcher for StubFetcher {
225        async fn fetch_ticketbooks(
226            &self,
227            _ticketbook_type: TicketType,
228        ) -> Result<Vec<NymCredential>, CredentialFetcherError> {
229            self.act().await
230        }
231
232        async fn cleanup(&self) {}
233
234        async fn reset(self) -> Result<(), CredentialFetcherError> {
235            Ok(())
236        }
237    }
238
239    fn today() -> Date {
240        nym_ecash_time::ecash_today_date()
241    }
242
243    fn is_signer_timeout(err: &CredentialFetcherError) -> bool {
244        err.to_string().contains("ecash signers unresponsive")
245    }
246
247    fn is_stub_error(err: &CredentialFetcherError) -> bool {
248        err.to_string().contains("stub inner error")
249    }
250
251    const PER_CALL: Duration = Duration::from_secs(15);
252
253    fn fetcher(mode: Mode) -> TimeoutFetcher<StubFetcher> {
254        TimeoutFetcher::with_timeout(StubFetcher { mode }, PER_CALL)
255    }
256
257    /// 3.2: a hanging public-data fetch yields a bounded `SignerTimeout`
258    /// instead of hanging — for each of the three fetches. The paused clock
259    /// auto-advances, so an actual hang would fail the test harness, not CI.
260    #[tokio::test(start_paused = true)]
261    async fn hanging_public_data_fetch_times_out() {
262        let f = fetcher(Mode::Hang);
263
264        let err = f
265            .fetch_expiration_date_signatures(today(), 0)
266            .await
267            .expect_err("must not hang");
268        assert!(is_signer_timeout(&err), "got: {err}");
269
270        let err = f
271            .fetch_master_verification_key(0)
272            .await
273            .expect_err("must not hang");
274        assert!(is_signer_timeout(&err), "got: {err}");
275
276        let err = f
277            .fetch_coin_index_signatures(0)
278            .await
279            .expect_err("must not hang");
280        assert!(is_signer_timeout(&err), "got: {err}");
281    }
282
283    /// 3.3 (under threshold): an inner call that completes just before the
284    /// bound surfaces its own outcome — proving the decorator waited.
285    #[tokio::test(start_paused = true)]
286    async fn slow_fetch_under_threshold_completes() {
287        let f = fetcher(Mode::ErrAfter(PER_CALL - Duration::from_secs(1)));
288        let err = f
289            .fetch_expiration_date_signatures(today(), 0)
290            .await
291            .expect_err("stub errors after delay");
292        assert!(
293            is_stub_error(&err),
294            "inner outcome must pass through: {err}"
295        );
296    }
297
298    /// 3.3 (over threshold): an inner call that would complete just after the
299    /// bound is cut off by `SignerTimeout` instead.
300    #[tokio::test(start_paused = true)]
301    async fn slow_fetch_over_threshold_times_out() {
302        let f = fetcher(Mode::ErrAfter(PER_CALL + Duration::from_secs(1)));
303        let err = f
304            .fetch_expiration_date_signatures(today(), 0)
305            .await
306            .expect_err("must time out");
307        assert!(is_signer_timeout(&err), "got: {err}");
308    }
309
310    /// 3.4: a genuine inner error passes through unaltered (no timeout wrapping).
311    #[tokio::test(start_paused = true)]
312    async fn immediate_inner_error_passes_through() {
313        let f = fetcher(Mode::Err);
314        let err = f
315            .fetch_expiration_date_signatures(today(), 0)
316            .await
317            .expect_err("stub errors");
318        assert!(is_stub_error(&err), "got: {err}");
319    }
320
321    /// 3.5: `fetch_ticketbooks` (deposit + issuance) is NOT timed. If the
322    /// decorator (wrongly) applied the per-call bound, the hanging inner call
323    /// would resolve to `SignerTimeout` at 15s — well inside the 1h outer
324    /// probe. The outer probe elapsing therefore proves issuance is unbounded
325    /// by the decorator. (Virtual clock: the hour passes instantly.)
326    #[tokio::test(start_paused = true)]
327    async fn fetch_ticketbooks_is_not_timed() {
328        let f = fetcher(Mode::Hang);
329        let probe = tokio::time::timeout(
330            Duration::from_secs(3600),
331            f.fetch_ticketbooks(TicketType::V1WireguardEntry),
332        )
333        .await;
334        assert!(
335            probe.is_err(),
336            "issuance must not be bounded by the public-data timeout"
337        );
338    }
339}