nym_sdk_session/
fetcher.rs1use 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
39pub const DEFAULT_PUBLIC_DATA_TIMEOUT: Duration = Duration::from_secs(15);
44
45#[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 FetcherErrorKind::Api
58 }
59}
60
61pub struct TimeoutFetcher<F> {
64 inner: F,
65 per_call: Duration,
66}
67
68impl<F> TimeoutFetcher<F> {
69 pub fn new(inner: F) -> Self {
71 Self::with_timeout(inner, DEFAULT_PUBLIC_DATA_TIMEOUT)
72 }
73
74 pub fn with_timeout(inner: F, per_call: Duration) -> Self {
76 TimeoutFetcher { inner, per_call }
77 }
78
79 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 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 #[derive(Clone, Copy)]
162 enum Mode {
163 Hang,
165 ErrAfter(Duration),
167 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 #[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 #[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 #[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 #[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 #[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}