Skip to main content

dash_sdk/
sync.rs

1pub use dash_async::{block_on, AsyncError};
2
3use crate::error::Error;
4use dash_context_provider::ContextProviderError;
5use rs_dapi_client::{
6    transport::sleep, update_address_ban_status, AddressList, CanRetry, DapiClientError,
7    ExecutionResult, RequestSettings,
8};
9use std::future::Future;
10use std::time::Duration;
11
12impl From<AsyncError> for crate::Error {
13    fn from(error: AsyncError) -> Self {
14        Self::ContextProviderError(error.into())
15    }
16}
17
18/// Retry the provided closure.
19///
20/// This function is used to retry async code. It takes into account number of retries already executed by lower
21/// layers and stops retrying once the maximum number of retries is reached.
22///
23/// The `settings` should contain maximum number of retries that should be executed. In case of failure, total number of
24/// requests sent is expected to be at least `settings.retries + 1` (initial request + `retries` configured in settings).
25/// The actual number of requests sent can be higher, as the lower layers can retry the request multiple times.
26///
27/// `future_factory_fn` should be a `FnMut()` closure that returns a future that should be retried.
28/// It takes [`RequestSettings`] as an argument and returns [`ExecutionResult`].
29/// Retry mechanism can change [`RequestSettings`] between invocations of the `future_factory_fn` closure
30/// to limit the number of retries for lower layers.
31///
32/// A retryable failure bans the node that caused it when banning is enabled, except a
33/// [`StaleNodeError::ProtocolVersion`](crate::error::StaleNodeError::ProtocolVersion): that
34/// node leaves the rotation without a ban, and a request whose every attempt ends with it
35/// fails with [`Error::Config`].
36///
37/// ## Parameters
38///
39/// - `address_list` - list of addresses to be used for the requests.
40/// - `settings` - global settings with any request-specific settings overrides applied.
41/// - `future_factory_fn` - closure that returns a future that should be retried. It should take [`RequestSettings`] as
42///   an argument and return [`ExecutionResult`].
43///
44/// ## Returns
45///
46/// Returns future that resolves to [`ExecutionResult`].
47///
48/// ## Example
49///
50/// ```rust
51/// # use dash_sdk::RequestSettings;
52/// # use dash_sdk::error::{Error,StaleNodeError};
53/// # use rs_dapi_client::{ExecutionResult, ExecutionError};
54/// async fn retry_test_function(settings: RequestSettings) -> ExecutionResult<(), dash_sdk::Error> {
55/// // do something
56///     Err(ExecutionError {
57///         inner: Error::StaleNode(StaleNodeError::Height{
58///             expected_height: 10,
59///             received_height: 3,
60///             tolerance_blocks: 1,
61///         }),
62///        retries: 0,
63///       address: None,
64///    })
65/// }
66/// #[tokio::main]
67///     async fn main() {
68///     let address_list = rs_dapi_client::AddressList::default();
69///     let global_settings = RequestSettings::default();
70///     dash_sdk::sync::retry(&address_list, global_settings, retry_test_function).await.expect_err("should fail");
71/// }
72/// ```
73///
74/// ## Troubleshooting
75///
76/// Compiler error: `no method named retry found for closure`:
77/// - ensure returned value is [`ExecutionResult`].
78/// - consider adding `.await` at the end of the closure.
79pub async fn retry<Fut, FutureFactoryFn, R>(
80    address_list: &AddressList,
81    settings: RequestSettings,
82    future_factory_fn: FutureFactoryFn,
83) -> ExecutionResult<R, Error>
84where
85    Fut: Future<Output = ExecutionResult<R, Error>>,
86    FutureFactoryFn: FnMut(RequestSettings) -> Fut,
87    R: Send,
88{
89    retry_with_additional_error(
90        address_list,
91        settings,
92        future_factory_fn,
93        attempt_has_quorum_source_failure,
94    )
95    .await
96}
97
98/// Whether the response could not be checked because the context provider's
99/// trusted quorum source gave no answer about the quorum that signed it. That
100/// says nothing about the node, so it is not banned through the health ladder;
101/// it is stepped over briefly, because another node may be signed by a quorum
102/// the provider already holds.
103/// This checks one attempt's error; retry-exhaustion wrappers describe the
104/// completed retry loop and must not trigger another attempt here.
105fn attempt_has_quorum_source_failure(error: &Error) -> bool {
106    matches!(
107        error,
108        Error::ContextProviderError(ContextProviderError::QuorumSourceUnavailable(_))
109    )
110}
111
112/// Retry a failure that is not retryable on its own, such as an
113/// operation-specific rejection or a response the client could not check,
114/// only when its responding node can be excluded. This does not change the
115/// error's global retry classification. Callers must restrict the predicate to
116/// failures that are safe to repeat on another node.
117pub(crate) async fn retry_with_additional_error<Fut, FutureFactoryFn, R, AdditionalError>(
118    address_list: &AddressList,
119    settings: RequestSettings,
120    mut future_factory_fn: FutureFactoryFn,
121    additional_error: AdditionalError,
122) -> ExecutionResult<R, Error>
123where
124    Fut: Future<Output = ExecutionResult<R, Error>>,
125    FutureFactoryFn: FnMut(RequestSettings) -> Fut,
126    R: Send,
127    AdditionalError: Fn(&Error) -> bool,
128{
129    let max_retries = settings.retries.unwrap_or_default();
130    let mut total_retries: usize = 0;
131    let mut current_settings = settings;
132
133    // Store the last meaningful error (not "no available addresses")
134    // so we can return it if we exhaust all addresses
135    let mut last_meaningful_error: Option<rs_dapi_client::ExecutionError<Error>> = None;
136
137    // Whether every attempt so far failed with an answer that spares its node. Only then
138    // does the request report the error such answers end in (`Error::into_exhausted_error`),
139    // bare whether its retries or its nodes ran out, so that callers and bindings classify it
140    // by that error rather than as a lack of nodes.
141    let mut every_attempt_spared = true;
142
143    loop {
144        let result = future_factory_fn(current_settings).await;
145
146        // Ban or unban the address based on the result. A retryable failure that does not
147        // show the node at fault moves the request off the node without banning it, as
148        // every retryable failure does when banning is disabled. Unlike the brief exclusion
149        // below, this leaves the node's ban state untouched: when the SDK is set ahead of its
150        // network every node answers this way, and none of them is to be marked as banned.
151        let mut applied_settings = current_settings.finalize();
152        if result
153            .as_ref()
154            .is_err_and(|error| error.inner.spares_responder())
155        {
156            applied_settings.ban_failed_address = false;
157        }
158        update_address_ban_status(address_list, &result, &applied_settings);
159
160        match result {
161            Ok(response) => return Ok(response),
162            Err(error) => {
163                // Check if this is a "no available addresses" error and we have a previous meaningful error
164                if error.is_no_available_addresses() {
165                    if let Some(prev_error) = last_meaningful_error.take() {
166                        tracing::error!(
167                            retry = total_retries,
168                            max_retries,
169                            error = ?prev_error,
170                            "no addresses available to retry"
171                        );
172                        // Wrap the last meaningful error in NoAvailableAddresses, unless
173                        // every attempt was spared, this one included: it reached no node,
174                        // rather than failing on the nodes it reached
175                        let inner = if every_attempt_spared
176                            && matches!(
177                                error.inner,
178                                Error::DapiClientError(DapiClientError::NoAvailableAddresses)
179                            ) {
180                            prev_error.inner.into_exhausted_error()
181                        } else {
182                            Error::NoAvailableAddressesToRetry(Box::new(prev_error.inner))
183                        };
184                        return Err(rs_dapi_client::ExecutionError {
185                            inner,
186                            retries: total_retries,
187                            address: prev_error.address,
188                        });
189                    }
190                    // No previous error, return the "no available addresses" error as-is
191                    return Err(error);
192                }
193
194                every_attempt_spared &= error.inner.spares_responder();
195
196                // Count requests sent in this attempt
197                let requests_sent = error.retries + 1;
198                total_retries += requests_sent;
199
200                let retry_additional_error = additional_error(&error.inner);
201                if !error.can_retry() && !retry_additional_error {
202                    // Non-retryable error, return immediately
203                    let mut final_error = error;
204                    final_error.retries = total_retries;
205                    return Err(final_error);
206                }
207
208                if total_retries > max_retries {
209                    // Exceeded max retries
210                    tracing::error!(
211                        retry = total_retries,
212                        max_retries,
213                        error = ?error,
214                        "no more retries left, giving up"
215                    );
216                    let mut final_error = error;
217                    final_error.retries = total_retries;
218                    if every_attempt_spared {
219                        final_error.inner = final_error.inner.into_exhausted_error();
220                    }
221                    return Err(final_error);
222                }
223
224                if retry_additional_error {
225                    // This failure does not establish a health failure. Use a
226                    // short flat exclusion, never the exponential health ladder.
227                    // The same node would answer the same way (a node can retain
228                    // rejected transaction hashes), so never send the request to
229                    // it again, including when the caller disabled banning or
230                    // the address is unknown. Only exclude it when a retry to
231                    // another node is possible; a single-node client must
232                    // remain usable after failure.
233                    let excluded = current_settings.finalize().ban_failed_address
234                        && error.address.as_ref().is_some_and(|address| {
235                            address_list
236                                .get_live_addresses()
237                                .iter()
238                                .any(|candidate| candidate != address)
239                                && address_list.ban_for(
240                                    address,
241                                    Duration::from_secs(2),
242                                    Some(error.to_string()),
243                                )
244                        });
245                    if !excluded || address_list.get_live_addresses().is_empty() {
246                        tracing::debug!(node = ?error.address, error = %error.inner, "failover stopped: no safely excluded alternative");
247                        let mut final_error = error;
248                        final_error.retries = total_retries;
249                        return Err(final_error);
250                    }
251                }
252
253                // Log retry decision (matches original `when()` callback)
254                tracing::warn!(
255                    retry = total_retries,
256                    max_retries,
257                    error = ?error,
258                    "retrying request"
259                );
260
261                // Update settings for next retry - limit retries for lower layer
262                current_settings.retries = Some(max_retries.saturating_sub(total_retries));
263
264                // Small delay to avoid spamming (we use different server, so no real delay needed)
265                // Log before sleep (matches original `notify()` callback)
266                let delay = Duration::from_millis(10);
267                tracing::warn!(duration = ?delay, error = ?error, "request failed, retrying");
268
269                // Store this as the last meaningful error before retrying
270                last_meaningful_error = Some(error);
271
272                sleep(delay).await;
273            }
274        }
275    }
276}
277
278#[cfg(test)]
279mod test {
280    use super::*;
281    use rs_dapi_client::ExecutionError;
282    use std::sync::{
283        atomic::{AtomicUsize, Ordering},
284        Arc,
285    };
286
287    use crate::error::StaleNodeError;
288    use rs_dapi_client::DapiClientError;
289
290    async fn retry_test_function(
291        settings: RequestSettings,
292        counter: Arc<AtomicUsize>,
293    ) -> ExecutionResult<(), Error> {
294        // num or retries increases with each call
295        let retries = counter.load(Ordering::Relaxed);
296        let retries = if settings.retries.unwrap_or_default() < retries {
297            settings.retries.unwrap_or_default()
298        } else {
299            retries
300        };
301
302        // we sent 1 initial request plus `retries` retries
303        counter.fetch_add(1 + retries, Ordering::Relaxed);
304
305        Err(ExecutionError {
306            inner: Error::StaleNode(StaleNodeError::Height {
307                expected_height: 100,
308                received_height: 50,
309                tolerance_blocks: 1,
310            }),
311            retries,
312            address: Some("http://localhost".parse().expect("valid address")),
313        })
314    }
315
316    #[test_case::test_matrix([1,2,3,5,7,8,10,11,23,49, usize::MAX])]
317    #[tokio::test]
318    async fn test_retry(expected_requests: usize) {
319        for _ in 0..1 {
320            let counter = Arc::new(AtomicUsize::new(0));
321
322            let address_list = AddressList::default();
323
324            // we retry 5 times, and expect 5 retries + 1 initial request
325            let mut global_settings = RequestSettings::default();
326            global_settings.retries = Some(expected_requests - 1);
327
328            let closure = |s| {
329                let counter = counter.clone();
330                retry_test_function(s, counter)
331            };
332
333            retry(&address_list, global_settings, closure)
334                .await
335                .expect_err("should fail");
336
337            assert_eq!(
338                counter.load(Ordering::Relaxed),
339                expected_requests,
340                "test failed for expected {} requests",
341                expected_requests
342            );
343        }
344    }
345
346    /// Test that when we get "no available addresses" error, we return the last meaningful error
347    /// wrapped in NoAvailableAddresses.
348    #[tokio::test]
349    async fn test_retry_returns_last_meaningful_error_on_no_addresses() {
350        let call_count = Arc::new(AtomicUsize::new(0));
351        let address_list = AddressList::default();
352
353        let mut settings = RequestSettings::default();
354        settings.retries = Some(5);
355
356        let call_count_clone = call_count.clone();
357        let closure = move |_settings: RequestSettings| {
358            let count = call_count_clone.fetch_add(1, Ordering::Relaxed);
359            async move {
360                if count == 0 {
361                    Err(ExecutionError {
362                        inner: Error::StaleNode(StaleNodeError::Height {
363                            expected_height: 100,
364                            received_height: 50,
365                            tolerance_blocks: 1,
366                        }),
367                        retries: 0,
368                        address: Some("http://localhost:1".parse().unwrap()),
369                    })
370                } else {
371                    Err(ExecutionError {
372                        inner: Error::DapiClientError(DapiClientError::NoAvailableAddresses),
373                        retries: 0,
374                        address: None,
375                    })
376                }
377            }
378        };
379
380        let result: ExecutionResult<(), Error> = retry(&address_list, settings, closure).await;
381
382        let error = result.expect_err("should fail");
383        match &error.inner {
384            Error::NoAvailableAddressesToRetry(inner) => {
385                assert!(
386                    matches!(**inner, Error::StaleNode(_)),
387                    "inner error should be StaleNode, got: {:?}",
388                    inner
389                );
390            }
391            _ => panic!(
392                "expected NoAvailableAddresses error, got: {:?}",
393                error.inner
394            ),
395        }
396        assert_eq!(
397            call_count.load(Ordering::Relaxed),
398            2,
399            "should have called twice"
400        );
401    }
402
403    /// An answer at an older protocol version that leaves its node unbanned still reads as a
404    /// configuration error when the request then finds no node left to ask. It is the same bare
405    /// error as when the retries run out: wrapped in the no-addresses error, the bindings would
406    /// report it as a network failure.
407    #[tokio::test]
408    async fn should_report_a_spared_answer_as_a_configuration_error_when_no_address_is_left() {
409        let mut address_list = AddressList::default();
410        let node: rs_dapi_client::Address = "http://localhost:1".parse().unwrap();
411        address_list.add(node.clone());
412        let settings = RequestSettings {
413            retries: Some(5),
414            ban_failed_address: Some(true),
415            ..Default::default()
416        };
417        let call_count = AtomicUsize::new(0);
418        let closure = |_settings: RequestSettings| {
419            let count = call_count.fetch_add(1, Ordering::Relaxed);
420            let node = node.clone();
421            async move {
422                Err::<rs_dapi_client::ExecutionResponse<()>, _>(if count == 0 {
423                    ExecutionError {
424                        inner: Error::StaleNode(StaleNodeError::ProtocolVersion {
425                            expected_protocol_version: 14,
426                            received_protocol_version: 13,
427                        }),
428                        retries: 0,
429                        address: Some(node),
430                    }
431                } else {
432                    ExecutionError {
433                        inner: Error::DapiClientError(DapiClientError::NoAvailableAddresses),
434                        retries: 0,
435                        address: None,
436                    }
437                })
438            }
439        };
440
441        let error = retry(&address_list, settings, closure)
442            .await
443            .expect_err("no node answers at the SDK's version");
444
445        assert!(matches!(error.inner, Error::Config(_)), "{:?}", error.inner);
446        assert_eq!(call_count.load(Ordering::Relaxed), 2);
447        assert!(!address_list.is_banned(&node), "the node is not banned");
448    }
449
450    /// A request that also failed in other ways does not end as a configuration error: only
451    /// a request whose every attempt was answered at an older protocol version does.
452    #[tokio::test]
453    async fn should_report_a_spared_answer_as_is_when_other_attempts_failed_otherwise() {
454        let address_list = AddressList::default();
455        let settings = RequestSettings {
456            retries: Some(1),
457            ..Default::default()
458        };
459        let call_count = AtomicUsize::new(0);
460        let closure = |_settings: RequestSettings| {
461            let count = call_count.fetch_add(1, Ordering::Relaxed);
462            async move {
463                Err::<rs_dapi_client::ExecutionResponse<()>, _>(ExecutionError {
464                    inner: if count == 0 {
465                        Error::StaleNode(StaleNodeError::Height {
466                            expected_height: 100,
467                            received_height: 50,
468                            tolerance_blocks: 1,
469                        })
470                    } else {
471                        Error::StaleNode(StaleNodeError::ProtocolVersion {
472                            expected_protocol_version: 14,
473                            received_protocol_version: 13,
474                        })
475                    },
476                    retries: 0,
477                    address: Some(format!("http://localhost:{}", 1 + count).parse().unwrap()),
478                })
479            }
480        };
481
482        let error = retry(&address_list, settings, closure)
483            .await
484            .expect_err("no attempt succeeds");
485
486        assert!(
487            matches!(
488                error.inner,
489                Error::StaleNode(StaleNodeError::ProtocolVersion { .. })
490            ),
491            "{:?}",
492            error.inner
493        );
494        assert_eq!(call_count.load(Ordering::Relaxed), 2);
495    }
496
497    fn protocol_version_answer() -> Error {
498        Error::StaleNode(StaleNodeError::ProtocolVersion {
499            expected_protocol_version: 14,
500            received_protocol_version: 13,
501        })
502    }
503
504    /// Runs `retry` over attempts that fail with `errors` in turn, each answer from its own node.
505    async fn retry_failing_with(errors: Vec<Error>) -> Error {
506        let address_list = AddressList::default();
507        let settings = RequestSettings {
508            retries: Some(5),
509            ..Default::default()
510        };
511        let errors = std::sync::Mutex::new(errors.into_iter().enumerate());
512        let closure = |_settings: RequestSettings| {
513            let (index, inner) = errors
514                .lock()
515                .expect("errors lock")
516                .next()
517                .expect("an error for every attempt");
518            async move {
519                let address = (!inner.is_no_available_addresses()).then(|| {
520                    format!("http://localhost:{}", 1 + index)
521                        .parse()
522                        .expect("address")
523                });
524                Err::<rs_dapi_client::ExecutionResponse<()>, _>(ExecutionError {
525                    inner,
526                    retries: 0,
527                    address,
528                })
529            }
530        };
531
532        retry(&address_list, settings, closure)
533            .await
534            .expect_err("no attempt succeeds")
535            .inner
536    }
537
538    fn is_wrapped_protocol_version_answer(error: &Error) -> bool {
539        matches!(
540            error,
541            Error::NoAvailableAddressesToRetry(inner)
542                if matches!(**inner, Error::StaleNode(StaleNodeError::ProtocolVersion { .. }))
543        )
544    }
545
546    /// Running out of nodes after attempts that also failed in other ways keeps the
547    /// no-addresses wrapper around the last answer, as is: only a request whose every attempt
548    /// was answered at an older protocol version ends as a configuration error.
549    #[tokio::test]
550    async fn should_keep_the_no_addresses_wrapper_when_an_earlier_attempt_failed_otherwise() {
551        let error = retry_failing_with(vec![
552            Error::StaleNode(StaleNodeError::Height {
553                expected_height: 100,
554                received_height: 50,
555                tolerance_blocks: 1,
556            }),
557            protocol_version_answer(),
558            Error::DapiClientError(DapiClientError::NoAvailableAddresses),
559        ])
560        .await;
561
562        assert!(is_wrapped_protocol_version_answer(&error), "{error:?}");
563    }
564
565    /// The attempt that ran out of nodes after failing on the nodes it reached, at the
566    /// transport level, was not answered at an older protocol version either, so the request
567    /// does not end as a configuration error.
568    #[tokio::test]
569    async fn should_keep_the_no_addresses_wrapper_when_the_last_attempt_failed_on_its_nodes() {
570        let error = retry_failing_with(vec![
571            protocol_version_answer(),
572            Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(Box::new(
573                rs_dapi_client::transport::TransportError::Grpc(
574                    dapi_grpc::tonic::Status::unavailable("node unreachable"),
575                ),
576            ))),
577        ])
578        .await;
579
580        assert!(is_wrapped_protocol_version_answer(&error), "{error:?}");
581    }
582
583    fn quorum_source_unavailable(address: &rs_dapi_client::Address) -> ExecutionError<Error> {
584        ExecutionError {
585            inner: Error::ContextProviderError(
586                dash_context_provider::ContextProviderError::QuorumSourceUnavailable(
587                    "quorum service unreachable".to_string(),
588                ),
589            ),
590            retries: 0,
591            address: Some(address.clone()),
592        }
593    }
594
595    fn two_nodes() -> (
596        AddressList,
597        rs_dapi_client::Address,
598        rs_dapi_client::Address,
599    ) {
600        let first: rs_dapi_client::Address = "http://127.0.0.1:1".parse().expect("address");
601        let second: rs_dapi_client::Address = "http://127.0.0.1:2".parse().expect("address");
602        let mut list = AddressList::new();
603        list.add(first.clone());
604        list.add(second.clone());
605        (list, first, second)
606    }
607
608    /// When the client's trusted quorum source gave no answer, the client
609    /// could not check the response, which says nothing about the node. The
610    /// request moves on, because another node may be signed by a quorum the
611    /// client already holds, and the node is only stepped over briefly.
612    #[tokio::test]
613    async fn should_fail_over_when_the_quorum_source_gave_no_answer() {
614        let (list, first, second) = two_nodes();
615        let calls = Arc::new(AtomicUsize::new(0));
616        let counted = Arc::clone(&calls);
617        let served = second.clone();
618        let failing = first.clone();
619        let closure = move |_settings: RequestSettings| {
620            let call = counted.fetch_add(1, Ordering::SeqCst);
621            let served = served.clone();
622            let failing = failing.clone();
623            async move {
624                if call == 0 {
625                    Err(quorum_source_unavailable(&failing))
626                } else {
627                    Ok(rs_dapi_client::ExecutionResponse {
628                        inner: (),
629                        retries: 0,
630                        address: served,
631                    })
632                }
633            }
634        };
635
636        let settings = RequestSettings {
637            retries: Some(3),
638            ..RequestSettings::default()
639        };
640
641        let response = retry(&list, settings, closure)
642            .await
643            .expect("another node answers");
644
645        assert_eq!(response.address, second);
646        assert_eq!(calls.load(Ordering::SeqCst), 2);
647        assert!(
648            !list.get_live_addresses().contains(&first),
649            "the node that answered is stepped over for a moment"
650        );
651        let excluded_until = list
652            .ban_info()
653            .into_iter()
654            .find(|info| info.uri == first.uri().to_string())
655            .and_then(|info| info.banned_until)
656            .expect("the node is excluded for a while");
657        assert!(
658            excluded_until <= chrono::Utc::now() + chrono::Duration::seconds(2),
659            "a short flat exclusion, not the health ladder"
660        );
661    }
662
663    /// A single-node client must stay usable, and a client that turned
664    /// banning off must not have its nodes excluded: the request fails with
665    /// the quorum source error instead of failing over.
666    #[test_case::test_case(false, None; "single node")]
667    #[test_case::test_case(true, Some(false); "banning off")]
668    #[tokio::test]
669    async fn should_not_fail_over_a_quorum_source_failure_without_an_excludable_node(
670        two_nodes_listed: bool,
671        ban_failed_address: Option<bool>,
672    ) {
673        let (list, first, _) = if two_nodes_listed {
674            two_nodes()
675        } else {
676            let first: rs_dapi_client::Address = "http://127.0.0.1:1".parse().expect("address");
677            let mut list = AddressList::new();
678            list.add(first.clone());
679            (list, first.clone(), first)
680        };
681        let calls = Arc::new(AtomicUsize::new(0));
682        let counted = Arc::clone(&calls);
683        let failing = first.clone();
684        let closure = move |_settings: RequestSettings| {
685            counted.fetch_add(1, Ordering::SeqCst);
686            let failing = failing.clone();
687            async move {
688                Err::<rs_dapi_client::ExecutionResponse<()>, _>(quorum_source_unavailable(&failing))
689            }
690        };
691        let settings = RequestSettings {
692            retries: Some(3),
693            ban_failed_address,
694            ..RequestSettings::default()
695        };
696
697        let error = retry(&list, settings, closure)
698            .await
699            .expect_err("no node can be stepped over");
700
701        assert!(matches!(
702            error.inner,
703            Error::ContextProviderError(
704                dash_context_provider::ContextProviderError::QuorumSourceUnavailable(_)
705            )
706        ));
707        assert_eq!(calls.load(Ordering::SeqCst), 1);
708        assert!(
709            list.get_live_addresses().contains(&first),
710            "no node is excluded"
711        );
712    }
713
714    /// Test that if we get "no available addresses" on the first call (no previous error),
715    /// we still return it as-is (not wrapped).
716    #[tokio::test]
717    async fn test_retry_returns_no_addresses_if_no_previous_error() {
718        let address_list = AddressList::default();
719
720        let mut settings = RequestSettings::default();
721        settings.retries = Some(5);
722
723        let closure = move |_settings: RequestSettings| async move {
724            Err(ExecutionError {
725                inner: Error::DapiClientError(DapiClientError::NoAvailableAddresses),
726                retries: 0,
727                address: None,
728            })
729        };
730
731        let result: ExecutionResult<(), Error> = retry(&address_list, settings, closure).await;
732
733        let error = result.expect_err("should fail");
734        assert!(
735            matches!(
736                error.inner,
737                Error::DapiClientError(DapiClientError::NoAvailableAddresses)
738            ),
739            "should return 'no available addresses' when there's no previous meaningful error, got: {:?}",
740            error.inner
741        );
742    }
743}