Skip to main content

rs_dapi_client/
dapi_client.rs

1//! [DapiClient] definition.
2
3use dapi_grpc::mock::Mockable;
4use dapi_grpc::tonic::async_trait;
5#[cfg(not(target_arch = "wasm32"))]
6use dapi_grpc::tonic::transport::Certificate;
7use dapi_grpc::tonic::Status;
8use std::fmt::{Debug, Display};
9use std::time::Duration;
10use tracing::Instrument;
11
12use crate::address_list::AddressListError;
13use crate::connection_pool::{ConnectionPool, DEFAULT_POOL_CAPACITY};
14use crate::request_settings::AppliedRequestSettings;
15#[cfg(not(target_arch = "wasm32"))]
16use crate::transport::Socks5Proxy;
17use crate::transport::{self, TransportError};
18use crate::{
19    transport::{TransportClient, TransportRequest},
20    AddressList, CanRetry, DapiRequestExecutor, ExecutionError, ExecutionResponse, ExecutionResult,
21    RequestSettings,
22};
23
24/// Intended minimum for the Envoy-advertised `RateLimit-Reset` ban duration.
25/// Note: the `> 0` filter applied before the clamp already rejects 0 → `None`,
26/// so this constant never actively clamps the lower bound — it documents intent
27/// (the smallest meaningful reset is 1 s) and acts as the `.clamp(MIN, MAX)`
28/// lower argument for clarity.
29pub(crate) const MIN_RATE_LIMIT_BAN_SECS: u64 = 1;
30/// Ceiling for the Envoy-advertised `RateLimit-Reset` ban duration.
31/// Prevents a misconfigured or hostile header from parking a healthy node for
32/// an unreasonably long time.
33pub(crate) const MAX_RATE_LIMIT_BAN_SECS: u64 = 600;
34
35/// General DAPI request error type.
36#[derive(Debug, thiserror::Error, Clone)]
37#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
38pub enum DapiClientError {
39    /// The error happened on transport layer
40    #[error("transport error: {0}")]
41    Transport(
42        #[cfg_attr(feature = "mocks", serde(with = "dapi_grpc::mock::serde_mockable"))]
43        TransportError,
44    ),
45    /// There are no valid DAPI addresses to use.
46    #[error("no available addresses to use")]
47    NoAvailableAddresses,
48    /// All available addresses have been exhausted (banned due to errors).
49    /// Contains the last meaningful error that caused addresses to be banned.
50    #[error("no available addresses to retry, last error: {0}")]
51    NoAvailableAddressesToRetry(
52        #[cfg_attr(feature = "mocks", serde(with = "dapi_grpc::mock::serde_mockable"))]
53        Box<TransportError>,
54    ),
55    /// [AddressListError] errors
56    #[error("address list error: {0}")]
57    AddressList(AddressListError),
58
59    #[cfg(feature = "mocks")]
60    #[error("mock error: {0}")]
61    /// Error happened in mock client
62    Mock(#[from] crate::mock::MockError),
63}
64
65impl CanRetry for DapiClientError {
66    fn can_retry(&self) -> bool {
67        use DapiClientError::*;
68        match self {
69            NoAvailableAddresses => false,
70            NoAvailableAddressesToRetry(_) => false,
71            Transport(transport_error) => transport_error.can_retry(),
72            AddressList(_) => false,
73            #[cfg(feature = "mocks")]
74            Mock(_) => false,
75        }
76    }
77
78    fn is_no_available_addresses(&self) -> bool {
79        matches!(
80            self,
81            DapiClientError::NoAvailableAddresses | DapiClientError::NoAvailableAddressesToRetry(_)
82        )
83    }
84
85    fn rate_limit_ban_duration(&self) -> Option<Duration> {
86        match self {
87            DapiClientError::Transport(te) => te.rate_limit_ban_duration(),
88            _ => None,
89        }
90    }
91}
92
93/// Serialization of [DapiClientError].
94///
95/// We need to do manual serialization because of the generic type parameter which doesn't support serde derive.
96impl Mockable for DapiClientError {
97    #[cfg(feature = "mocks")]
98    fn mock_serialize(&self) -> Option<Vec<u8>> {
99        Some(serde_json::to_vec(self).expect("serialize DAPI client error"))
100    }
101
102    #[cfg(feature = "mocks")]
103    fn mock_deserialize(data: &[u8]) -> Option<Self> {
104        Some(serde_json::from_slice(data).expect("deserialize DAPI client error"))
105    }
106}
107
108/// Access point to DAPI.
109#[derive(Debug, Clone)]
110pub struct DapiClient {
111    address_list: AddressList,
112    settings: RequestSettings,
113    pool: ConnectionPool,
114    #[cfg(not(target_arch = "wasm32"))]
115    /// Certificate Authority certificate to use for verifying the server's certificate.
116    pub ca_certificate: Option<Certificate>,
117    #[cfg(not(target_arch = "wasm32"))]
118    /// SOCKS5 proxy every connection is tunnelled through.
119    pub proxy: Option<Socks5Proxy>,
120    #[cfg(feature = "dump")]
121    pub(crate) dump_dir: Option<std::path::PathBuf>,
122}
123
124impl DapiClient {
125    /// Initialize new [DapiClient] and optionally override default settings.
126    ///
127    /// `address_list` may be empty; addresses added later to the shared list
128    /// (or a clone of it) are used by this client.
129    pub fn new(address_list: AddressList, settings: RequestSettings) -> Self {
130        // multiply by 3 as we need to store core and platform addresses, and we want some spare capacity just in case;
131        // never go below the default, as the list can be empty and addresses can be added later
132        let pool_capacity = (3 * address_list.len()).max(DEFAULT_POOL_CAPACITY);
133
134        Self {
135            address_list,
136            settings,
137            pool: ConnectionPool::new(pool_capacity),
138            #[cfg(feature = "dump")]
139            dump_dir: None,
140            #[cfg(not(target_arch = "wasm32"))]
141            ca_certificate: None,
142            #[cfg(not(target_arch = "wasm32"))]
143            proxy: None,
144        }
145    }
146
147    /// Set CA certificate to use when verifying the server's certificate.
148    ///
149    /// # Arguments
150    ///
151    /// * `pem_ca_cert` - CA certificate in PEM format.
152    ///
153    /// # Returns
154    /// [DapiClient] with CA certificate set.
155    #[cfg(not(target_arch = "wasm32"))]
156    pub fn with_ca_certificate(mut self, ca_cert: Certificate) -> Self {
157        self.ca_certificate = Some(ca_cert);
158
159        self
160    }
161
162    /// Tunnel every connection through a SOCKS5 proxy.
163    ///
164    /// There is no fallback to a direct connection. A request that fails
165    /// because the proxy failed is not retried and does not ban the node
166    /// (see [TransportError::is_proxy_failure]).
167    ///
168    /// # Returns
169    /// [DapiClient] with the proxy set.
170    #[cfg(not(target_arch = "wasm32"))]
171    pub fn with_proxy(mut self, proxy: Socks5Proxy) -> Self {
172        self.proxy = Some(proxy);
173
174        self
175    }
176
177    /// Return the [DapiClient] address list.
178    pub fn address_list(&self) -> &AddressList {
179        &self.address_list
180    }
181
182    /// Get all non-banned addresses from the address list.
183    ///
184    /// Returns a vector of addresses that are not currently banned or whose ban period has expired.
185    /// This is useful for diagnostics, monitoring, or when you need to know which DAPI nodes are
186    /// currently available for making requests.
187    ///
188    /// # Examples
189    ///
190    /// ```no_run
191    /// use rs_dapi_client::{DapiClient, AddressList, RequestSettings};
192    ///
193    /// let address_list = "http://127.0.0.1:3000,http://127.0.0.1:3001".parse().unwrap();
194    /// let client = DapiClient::new(address_list, RequestSettings::default());
195    ///
196    /// // Get all currently available (non-banned) addresses
197    /// let live_addresses = client.get_live_addresses();
198    /// println!("Available DAPI nodes: {}", live_addresses.len());
199    /// ```
200    pub fn get_live_addresses(&self) -> Vec<crate::Address> {
201        self.address_list.get_live_addresses()
202    }
203}
204
205/// Ban address in case of retryable error or unban it
206/// if it was banned, and the request was successful.
207pub fn update_address_ban_status<R, E>(
208    address_list: &AddressList,
209    result: &ExecutionResult<R, E>,
210    applied_settings: &AppliedRequestSettings,
211) where
212    E: CanRetry + Display + Debug,
213{
214    match &result {
215        Ok(response) => {
216            // Unban the address if it was banned and node responded successfully this time
217            if address_list.is_banned(&response.address) {
218                if address_list.unban(&response.address) {
219                    tracing::debug!(address = ?response.address, "unban successfully responded address {}", response.address);
220                } else {
221                    // The address might be already removed from the list
222                    // by background process (i.e., SML update), and it's fine.
223                    tracing::debug!(
224                        address = ?response.address,
225                        "unable to unban address {} because it's not in the list anymore",
226                        response.address
227                    );
228                }
229            }
230        }
231        Err(error) => {
232            if error.can_retry() {
233                if let Some(address) = error.address.as_ref() {
234                    if applied_settings.ban_failed_address {
235                        let reason = Some(error.to_string());
236                        let period_opt = error.rate_limit_ban_duration();
237                        let banned = match period_opt {
238                            // Envoy advertised a reset window: ban for exactly that period.
239                            // ban_count is set to max(ban_count,1) so diagnostics see the node
240                            // as banned, but the exponential ladder is not inflated.
241                            Some(period) => address_list.ban_for(address, period, reason),
242                            // No rate-limit hint: normal exponential health-ban ladder.
243                            None => address_list.ban_with_reason(address, reason),
244                        };
245                        if banned {
246                            if let Some(period) = period_opt {
247                                tracing::debug!(
248                                    ?address,
249                                    ban_secs = period.as_secs(),
250                                    "rate-limited (ResourceExhausted): banning {address} for {}s (from RateLimit-Reset header)",
251                                    period.as_secs()
252                                );
253                            }
254                            tracing::warn!(
255                                ?address,
256                                ?error,
257                                "ban address {address} due to error: {error}"
258                            );
259                        } else {
260                            // The address might be already removed from the list
261                            // by background process (i.e., SML update), and it's fine.
262                            tracing::debug!(
263                                ?address,
264                                ?error,
265                                "unable to ban address {address} because it's not in the list anymore"
266                            );
267                        }
268                    } else {
269                        // Banning is disabled for this request, but failover
270                        // must still move traffic away from the failing node:
271                        // drop it from the sticky rotation, ban state untouched.
272                        address_list.evict_from_rotation(address);
273                        tracing::debug!(
274                            ?error,
275                            ?address,
276                            "banning is disabled; evicted address {address} from rotation due to the error"
277                        );
278                    }
279                } else {
280                    tracing::debug!(
281                        ?error,
282                        "we should ban an address due to the error but address is absent"
283                    );
284                }
285            }
286        }
287    };
288}
289
290#[cfg(test)]
291#[allow(clippy::items_after_test_module)]
292mod tests {
293    use super::*;
294
295    fn mock_address() -> crate::Address {
296        "http://127.0.0.1:3000".parse().expect("valid address")
297    }
298
299    fn make_applied_settings(ban: bool) -> AppliedRequestSettings {
300        AppliedRequestSettings {
301            connect_timeout: None,
302            timeout: Duration::from_secs(10),
303            retries: 5,
304            ban_failed_address: ban,
305            max_decoding_message_size: None,
306            #[cfg(not(target_arch = "wasm32"))]
307            ca_certificate: None,
308            #[cfg(not(target_arch = "wasm32"))]
309            proxy: None,
310        }
311    }
312
313    #[tokio::test]
314    async fn test_new_with_empty_address_list() {
315        let client = DapiClient::new(AddressList::new(), RequestSettings::default());
316        assert!(client.address_list().is_empty());
317
318        let request = dapi_grpc::platform::v0::GetIdentityRequest::default();
319        let err = client
320            .execute(request, RequestSettings::default())
321            .await
322            .expect_err("no addresses to execute the request on");
323        assert!(matches!(err.inner, DapiClientError::NoAvailableAddresses));
324
325        // The address list is shared, so addresses added later are visible to the client.
326        let mut address_list = client.address_list().clone();
327        assert!(address_list.add(mock_address()));
328        assert_eq!(client.get_live_addresses(), vec![mock_address()]);
329        // Execution on the added address: tests/empty_address_list.rs.
330    }
331
332    #[test]
333    fn test_can_retry_no_available_addresses() {
334        let err = DapiClientError::NoAvailableAddresses;
335        assert!(!err.can_retry());
336    }
337
338    #[test]
339    fn test_can_retry_no_available_addresses_to_retry() {
340        let transport_err = TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("gone"));
341        let err = DapiClientError::NoAvailableAddressesToRetry(Box::new(transport_err));
342        assert!(!err.can_retry());
343    }
344
345    #[test]
346    fn test_can_retry_transport_retryable() {
347        let transport_err =
348            TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
349        let err = DapiClientError::Transport(transport_err);
350        assert!(err.can_retry());
351    }
352
353    #[test]
354    fn test_can_retry_transport_non_retryable() {
355        let transport_err = TransportError::Grpc(dapi_grpc::tonic::Status::not_found("permanent"));
356        let err = DapiClientError::Transport(transport_err);
357        assert!(!err.can_retry());
358    }
359
360    #[test]
361    fn test_can_retry_address_list_error() {
362        let err =
363            DapiClientError::AddressList(AddressListError::InvalidAddressUri("bad".to_string()));
364        assert!(!err.can_retry());
365    }
366
367    /// `rate_limit_ban_duration` returns `Some` only when the `ratelimit-reset`
368    /// header is present and positive on a `ResourceExhausted` response, and
369    /// the value is clamped to `[MIN_RATE_LIMIT_BAN_SECS, MAX_RATE_LIMIT_BAN_SECS]`.
370    #[test]
371    fn test_rate_limit_ban_duration_header_parse() {
372        use dapi_grpc::tonic::metadata::MetadataValue;
373
374        // Helper: build a ResourceExhausted status with a ratelimit-reset header.
375        let make_rl_status = |header: Option<&str>| -> dapi_grpc::tonic::Status {
376            let mut status = dapi_grpc::tonic::Status::resource_exhausted("429");
377            if let Some(v) = header {
378                status
379                    .metadata_mut()
380                    .insert("ratelimit-reset", MetadataValue::try_from(v).unwrap());
381            }
382            status
383        };
384
385        // Normal header value: returned clamped.
386        let s = make_rl_status(Some("45"));
387        let dur = TransportError::Grpc(s).rate_limit_ban_duration();
388        assert_eq!(dur, Some(Duration::from_secs(45)));
389
390        // Value above MAX → clamped to MAX.
391        let s = make_rl_status(Some("9999"));
392        let dur = TransportError::Grpc(s).rate_limit_ban_duration();
393        assert_eq!(dur, Some(Duration::from_secs(MAX_RATE_LIMIT_BAN_SECS)));
394
395        // Clamp edge: exactly MIN (1) → 1 s (passes through unchanged).
396        let s = make_rl_status(Some("1"));
397        assert_eq!(
398            TransportError::Grpc(s).rate_limit_ban_duration(),
399            Some(Duration::from_secs(1))
400        );
401
402        // Clamp edge: exactly MAX (600) → 600 s (not clamped).
403        let s = make_rl_status(Some("600"));
404        assert_eq!(
405            TransportError::Grpc(s).rate_limit_ban_duration(),
406            Some(Duration::from_secs(600))
407        );
408
409        // One above MAX (601) → clamped to 600 s.
410        let s = make_rl_status(Some("601"));
411        assert_eq!(
412            TransportError::Grpc(s).rate_limit_ban_duration(),
413            Some(Duration::from_secs(600))
414        );
415
416        // Value below MIN (0) → filtered to None before clamp.
417        let s = make_rl_status(Some("0"));
418        assert!(TransportError::Grpc(s).rate_limit_ban_duration().is_none());
419
420        // Non-numeric → None.
421        let s = make_rl_status(Some("garbage"));
422        assert!(TransportError::Grpc(s).rate_limit_ban_duration().is_none());
423
424        // Header absent → None.
425        let s = make_rl_status(None);
426        assert!(TransportError::Grpc(s).rate_limit_ban_duration().is_none());
427
428        // Non-ResourceExhausted code → None regardless of header.
429        let mut unavail = dapi_grpc::tonic::Status::unavailable("down");
430        unavail
431            .metadata_mut()
432            .insert("ratelimit-reset", MetadataValue::try_from("30").unwrap());
433        assert!(TransportError::Grpc(unavail)
434            .rate_limit_ban_duration()
435            .is_none());
436    }
437
438    /// When `ResourceExhausted` carries a valid `ratelimit-reset` header,
439    /// `update_address_ban_status` calls `ban_for` (exact period, no ladder
440    /// inflation); when the header is absent it falls through to `ban_with_reason`
441    /// (normal exponential ladder).
442    #[test]
443    fn test_update_address_ban_status_rate_limit_ban_path() {
444        use dapi_grpc::tonic::metadata::MetadataValue;
445
446        let mut address_list = AddressList::new();
447        let addr = mock_address();
448        address_list.add(addr.clone());
449
450        // Build a ResourceExhausted status with ratelimit-reset: 45.
451        let mut status = dapi_grpc::tonic::Status::resource_exhausted("429");
452        status
453            .metadata_mut()
454            .insert("ratelimit-reset", MetadataValue::try_from("45").unwrap());
455
456        let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
457            inner: DapiClientError::Transport(TransportError::Grpc(status)),
458            retries: 0,
459            address: Some(addr.clone()),
460        });
461        let before = chrono::Utc::now();
462        update_address_ban_status(&address_list, &result, &make_applied_settings(true));
463        let after = chrono::Utc::now();
464
465        let info = address_list.ban_info();
466        let entry = info.iter().find(|i| i.uri == addr.to_string()).unwrap();
467
468        // Node is banned for ~45 s.
469        assert!(entry.banned, "rate-limited node must be banned");
470        assert_eq!(entry.ban_count, 1, "ban_count must be 1 after ban_for");
471        let until = entry.banned_until.expect("banned_until set");
472        let lo = (until - before).num_milliseconds() as f64 / 1000.0;
473        let hi = (until - after).num_milliseconds() as f64 / 1000.0;
474        assert!(
475            lo >= 44.9 && hi <= 45.1,
476            "ban window must be ~45 s, got lo={lo} hi={hi}"
477        );
478    }
479
480    /// When `ResourceExhausted` has NO `ratelimit-reset` header,
481    /// `update_address_ban_status` must fall back to the normal `ban_with_reason`
482    /// ladder (not produce a zero-second or panic ban).
483    #[test]
484    fn test_update_address_ban_status_rate_limit_no_header_uses_ladder() {
485        let mut address_list = AddressList::new();
486        let addr = mock_address();
487        address_list.add(addr.clone());
488
489        let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
490            inner: DapiClientError::Transport(TransportError::Grpc(
491                dapi_grpc::tonic::Status::resource_exhausted("429"),
492            )),
493            retries: 0,
494            address: Some(addr.clone()),
495        });
496        update_address_ban_status(&address_list, &result, &make_applied_settings(true));
497
498        // The ban ladder is invoked: first ban → ban_count = 1, window = 60 s.
499        let info = address_list.ban_info();
500        let entry = info.iter().find(|i| i.uri == addr.to_string()).unwrap();
501        assert!(
502            entry.banned,
503            "node must be banned on ResourceExhausted without header"
504        );
505        assert_eq!(
506            entry.ban_count, 1,
507            "first health-ladder ban → ban_count = 1"
508        );
509    }
510
511    #[cfg(feature = "mocks")]
512    #[test]
513    fn test_can_retry_mock_error() {
514        let err = DapiClientError::Mock(crate::mock::MockError::MockExpectationNotFound(
515            "test".to_string(),
516        ));
517        assert!(!err.can_retry());
518    }
519
520    #[test]
521    fn test_is_no_available_addresses() {
522        assert!(DapiClientError::NoAvailableAddresses.is_no_available_addresses());
523
524        let transport_err = TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("gone"));
525        assert!(
526            DapiClientError::NoAvailableAddressesToRetry(Box::new(transport_err))
527                .is_no_available_addresses()
528        );
529
530        let transport_err =
531            TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
532        assert!(!DapiClientError::Transport(transport_err).is_no_available_addresses());
533    }
534
535    #[test]
536    fn test_update_address_ban_status_success_unbans() {
537        let mut address_list = AddressList::new();
538        let addr = mock_address();
539        address_list.add(addr.clone());
540        address_list.ban(&addr);
541        assert!(address_list.is_banned(&addr));
542
543        let result: ExecutionResult<i32, DapiClientError> = Ok(ExecutionResponse {
544            inner: 42,
545            retries: 0,
546            address: addr.clone(),
547        });
548
549        update_address_ban_status(&address_list, &result, &make_applied_settings(true));
550
551        assert!(!address_list.is_banned(&addr));
552    }
553
554    #[test]
555    fn test_update_address_ban_status_success_on_unbanned_is_noop() {
556        let mut address_list = AddressList::new();
557        let addr = mock_address();
558        address_list.add(addr.clone());
559
560        let result: ExecutionResult<i32, DapiClientError> = Ok(ExecutionResponse {
561            inner: 42,
562            retries: 0,
563            address: addr.clone(),
564        });
565
566        // Should not panic or change anything
567        update_address_ban_status(&address_list, &result, &make_applied_settings(true));
568        assert!(!address_list.is_banned(&addr));
569    }
570
571    #[test]
572    fn test_update_address_ban_status_retryable_error_bans_address() {
573        let mut address_list = AddressList::new();
574        let addr = mock_address();
575        address_list.add(addr.clone());
576
577        let transport_err =
578            TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
579        let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
580            inner: DapiClientError::Transport(transport_err),
581            retries: 0,
582            address: Some(addr.clone()),
583        });
584
585        update_address_ban_status(&address_list, &result, &make_applied_settings(true));
586        assert!(address_list.is_banned(&addr));
587
588        // The ban reason must be propagated from the error via this call path,
589        // not just the ban itself.
590        let info = address_list.ban_info();
591        assert_eq!(info.len(), 1);
592        let reason = info[0].reason.as_deref().expect("ban reason recorded");
593        assert!(
594            reason.contains("temporary"),
595            "ban reason should carry the underlying error, got: {reason}"
596        );
597    }
598
599    #[test]
600    fn test_update_address_ban_status_retryable_error_ban_disabled() {
601        let mut address_list = AddressList::new();
602        let addr = mock_address();
603        address_list.add(addr.clone());
604
605        let transport_err =
606            TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
607        let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
608            inner: DapiClientError::Transport(transport_err),
609            retries: 0,
610            address: Some(addr.clone()),
611        });
612
613        update_address_ban_status(&address_list, &result, &make_applied_settings(false));
614        // With ban disabled, the address should NOT be banned
615        assert!(!address_list.is_banned(&addr));
616    }
617
618    #[test]
619    fn test_update_address_ban_status_non_retryable_error_does_not_ban() {
620        let mut address_list = AddressList::new();
621        let addr = mock_address();
622        address_list.add(addr.clone());
623
624        let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
625            inner: DapiClientError::NoAvailableAddresses,
626            retries: 0,
627            address: Some(addr.clone()),
628        });
629
630        update_address_ban_status(&address_list, &result, &make_applied_settings(true));
631        assert!(!address_list.is_banned(&addr));
632    }
633
634    #[test]
635    fn test_update_address_ban_status_retryable_error_no_address() {
636        let address_list = AddressList::new();
637
638        let transport_err =
639            TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
640        let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
641            inner: DapiClientError::Transport(transport_err),
642            retries: 0,
643            address: None,
644        });
645
646        // Should not panic when address is None
647        update_address_ban_status(&address_list, &result, &make_applied_settings(true));
648    }
649
650    #[test]
651    fn test_update_address_ban_status_unban_removed_address() {
652        let mut address_list = AddressList::new();
653        let addr = mock_address();
654        address_list.add(addr.clone());
655        address_list.ban(&addr);
656
657        // Remove the address
658        address_list.remove(&addr);
659
660        let result: ExecutionResult<i32, DapiClientError> = Ok(ExecutionResponse {
661            inner: 42,
662            retries: 0,
663            address: addr.clone(),
664        });
665
666        // Should not panic when trying to unban a removed address
667        update_address_ban_status(&address_list, &result, &make_applied_settings(true));
668    }
669
670    #[test]
671    fn test_update_address_ban_status_ban_removed_address() {
672        let address_list = AddressList::new();
673        let addr = mock_address();
674
675        let transport_err =
676            TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
677        let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
678            inner: DapiClientError::Transport(transport_err),
679            retries: 0,
680            address: Some(addr),
681        });
682
683        // Should not panic when trying to ban an address not in the list
684        update_address_ban_status(&address_list, &result, &make_applied_settings(true));
685    }
686
687    #[test]
688    fn test_dapi_client_new() {
689        let address_list: AddressList = "http://127.0.0.1:3000,http://127.0.0.1:3001"
690            .parse()
691            .unwrap();
692        let client = DapiClient::new(address_list, RequestSettings::default());
693        assert_eq!(client.address_list().len(), 2);
694    }
695
696    #[test]
697    fn test_dapi_client_get_live_addresses() {
698        let address_list: AddressList = "http://127.0.0.1:3000,http://127.0.0.1:3001"
699            .parse()
700            .unwrap();
701        let client = DapiClient::new(address_list, RequestSettings::default());
702        let live = client.get_live_addresses();
703        assert_eq!(live.len(), 2);
704    }
705
706    #[cfg(not(target_arch = "wasm32"))]
707    #[test]
708    fn test_dapi_client_with_ca_certificate() {
709        let address_list: AddressList = "http://127.0.0.1:3000".parse().unwrap();
710        let client = DapiClient::new(address_list, RequestSettings::default());
711        let cert = dapi_grpc::tonic::transport::Certificate::from_pem("fake-pem-data");
712        let client = client.with_ca_certificate(cert);
713        assert!(client.ca_certificate.is_some());
714    }
715
716    #[cfg(not(target_arch = "wasm32"))]
717    #[test]
718    fn test_dapi_client_with_proxy() {
719        use crate::transport::{ProxyEndpoint, Socks5Auth};
720
721        let address_list: AddressList = "http://127.0.0.1:3000".parse().unwrap();
722        let proxy = Socks5Proxy {
723            endpoint: ProxyEndpoint::Tcp("127.0.0.1:9050".parse().unwrap()),
724            auth: Socks5Auth::RandomPerConnection,
725        };
726        let client =
727            DapiClient::new(address_list, RequestSettings::default()).with_proxy(proxy.clone());
728        assert_eq!(client.proxy, Some(proxy));
729    }
730
731    #[cfg(feature = "mocks")]
732    #[test]
733    fn test_dapi_client_error_mock_serialize_deserialize() {
734        use dapi_grpc::mock::Mockable;
735
736        let err = DapiClientError::NoAvailableAddresses;
737        let serialized = err.mock_serialize().expect("should serialize");
738        let deserialized =
739            DapiClientError::mock_deserialize(&serialized).expect("should deserialize");
740        assert!(matches!(
741            deserialized,
742            DapiClientError::NoAvailableAddresses
743        ));
744    }
745
746    #[cfg(feature = "mocks")]
747    #[test]
748    fn test_dapi_client_error_transport_mock_roundtrip() {
749        use dapi_grpc::mock::Mockable;
750
751        let transport_err = TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("test"));
752        let err = DapiClientError::Transport(transport_err);
753        let serialized = err.mock_serialize().expect("should serialize");
754        let deserialized =
755            DapiClientError::mock_deserialize(&serialized).expect("should deserialize");
756        assert!(matches!(deserialized, DapiClientError::Transport(_)));
757    }
758
759    #[test]
760    fn test_dapi_client_error_display() {
761        let err = DapiClientError::NoAvailableAddresses;
762        let display = format!("{}", err);
763        assert!(display.contains("no available addresses"));
764
765        let transport_err = TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("gone"));
766        let err = DapiClientError::NoAvailableAddressesToRetry(Box::new(transport_err));
767        let display = format!("{}", err);
768        assert!(display.contains("no available addresses to retry"));
769
770        let err =
771            DapiClientError::AddressList(AddressListError::InvalidAddressUri("bad".to_string()));
772        let display = format!("{}", err);
773        assert!(display.contains("address list error"));
774    }
775
776    /// Executor-level coverage for evicting the pooled connection of a node
777    /// whose attempt missed its deadline.
778    #[cfg(not(target_arch = "wasm32"))]
779    mod deadline_pool_eviction {
780        use super::*;
781        use crate::connection_pool::{PoolItem, PoolPrefix};
782        use crate::transport::{BoxFuture, PlatformGrpcClient};
783        use crate::Uri;
784        use dapi_grpc::tonic::transport::Channel;
785        use dapi_grpc::tonic::Code;
786        use std::sync::{Arc, Mutex};
787
788        /// Takes its connection from the executor's pool, as the real gRPC
789        /// clients do, so the pool holds an entry for every node dialed.
790        struct PooledClient {
791            uri: Uri,
792        }
793
794        impl PooledClient {
795            fn pooled(
796                uri: Uri,
797                settings: Option<&AppliedRequestSettings>,
798                pool: &ConnectionPool,
799            ) -> Result<Self, TransportError> {
800                pool.get_or_create(PoolPrefix::Platform, &uri, settings, || {
801                    Ok::<_, TransportError>(PoolItem::Platform(PlatformGrpcClient::new(
802                        Channel::builder(uri.clone()).connect_lazy(),
803                    )))
804                })?;
805                Ok(Self { uri })
806            }
807        }
808
809        impl TransportClient for PooledClient {
810            fn with_uri(uri: Uri, pool: &ConnectionPool) -> Result<Self, TransportError> {
811                Self::pooled(uri, None, pool)
812            }
813
814            fn with_uri_and_settings(
815                uri: Uri,
816                settings: &AppliedRequestSettings,
817                pool: &ConnectionPool,
818            ) -> Result<Self, TransportError> {
819                Self::pooled(uri, Some(settings), pool)
820            }
821        }
822
823        #[derive(Debug)]
824        struct Pong;
825
826        impl Mockable for Pong {}
827
828        /// Never answers on the first node it is sent to; answers at once
829        /// everywhere else.
830        #[derive(Clone, Debug, Default)]
831        struct StallFirstRequest {
832            stalled: Arc<Mutex<Option<Uri>>>,
833        }
834
835        impl Mockable for StallFirstRequest {}
836
837        impl TransportRequest for StallFirstRequest {
838            type Client = PooledClient;
839            type Response = Pong;
840
841            const SETTINGS_OVERRIDES: RequestSettings = RequestSettings::default();
842
843            fn method_name(&self) -> &'static str {
844                "stall_first"
845            }
846
847            fn execute_transport<'c>(
848                self,
849                client: &'c mut Self::Client,
850                _settings: &AppliedRequestSettings,
851            ) -> BoxFuture<'c, Result<Self::Response, TransportError>> {
852                let mut stalled = self.stalled.lock().expect("stall lock");
853                let target = stalled.get_or_insert_with(|| client.uri.clone());
854                if *target == client.uri {
855                    Box::pin(futures::future::pending())
856                } else {
857                    Box::pin(async { Ok(Pong) })
858                }
859            }
860        }
861
862        #[tokio::test(start_paused = true)]
863        async fn should_evict_the_pooled_connection_of_a_node_that_missed_its_deadline() {
864            let request = StallFirstRequest::default();
865            let client = DapiClient::new(
866                "http://127.0.0.1:10001,http://127.0.0.1:10002"
867                    .parse()
868                    .expect("valid address list"),
869                RequestSettings::default(),
870            );
871
872            let response = client
873                .execute(request.clone(), RequestSettings::default())
874                .await
875                .expect("the other node must answer");
876
877            let stalled = request
878                .stalled
879                .lock()
880                .expect("stall lock")
881                .clone()
882                .expect("a node stalled");
883            // The executor's applied settings for these defaults (no CA
884            // certificate) produce the same pool key.
885            let settings = RequestSettings::default().finalize();
886            assert!(
887                client
888                    .pool
889                    .get(PoolPrefix::Platform, &stalled, Some(&settings))
890                    .is_none(),
891                "the stalled node's connection must be evicted"
892            );
893            assert!(
894                client
895                    .pool
896                    .get(
897                        PoolPrefix::Platform,
898                        response.address.uri(),
899                        Some(&settings)
900                    )
901                    .is_some(),
902                "the healthy node's connection must stay pooled"
903            );
904        }
905
906        /// Replaces its node's pooled connection, as a concurrent request
907        /// that timed out on the node and reconnected would, then never
908        /// answers.
909        #[derive(Clone, Debug)]
910        struct ReplaceConnectionThenStall {
911            pool: ConnectionPool,
912        }
913
914        impl Mockable for ReplaceConnectionThenStall {}
915
916        impl TransportRequest for ReplaceConnectionThenStall {
917            type Client = PooledClient;
918            type Response = Pong;
919
920            const SETTINGS_OVERRIDES: RequestSettings = RequestSettings::default();
921
922            fn method_name(&self) -> &'static str {
923                "replace_connection_then_stall"
924            }
925
926            fn execute_transport<'c>(
927                self,
928                client: &'c mut Self::Client,
929                settings: &AppliedRequestSettings,
930            ) -> BoxFuture<'c, Result<Self::Response, TransportError>> {
931                self.pool.put(
932                    &client.uri,
933                    Some(settings),
934                    PoolItem::Platform(PlatformGrpcClient::new(
935                        Channel::builder(client.uri.clone()).connect_lazy(),
936                    )),
937                );
938                Box::pin(futures::future::pending())
939            }
940        }
941
942        #[tokio::test(start_paused = true)]
943        async fn should_keep_a_connection_pooled_after_the_attempt_took_its_own() {
944            let client = DapiClient::new(
945                "http://127.0.0.1:10001"
946                    .parse()
947                    .expect("valid address list"),
948                RequestSettings {
949                    retries: Some(0),
950                    ..RequestSettings::default()
951                },
952            );
953            let request = ReplaceConnectionThenStall {
954                pool: client.pool.clone(),
955            };
956
957            let error = client
958                .execute(request, RequestSettings::default())
959                .await
960                .expect_err("the only node never answers");
961
962            assert!(
963                matches!(
964                    &error.inner,
965                    DapiClientError::Transport(TransportError::Grpc(status))
966                        if status.code() == Code::DeadlineExceeded
967                ),
968                "expected DeadlineExceeded, got {:?}",
969                error.inner
970            );
971            let uri = error.address.expect("the attempted node").uri().clone();
972            let settings = RequestSettings::default().finalize();
973            assert!(
974                client
975                    .pool
976                    .get(PoolPrefix::Platform, &uri, Some(&settings))
977                    .is_some(),
978                "a connection pooled after the attempt took its own must stay pooled"
979            );
980        }
981
982        /// Takes its node's pooled connection, which another worker replaces
983        /// before the constructor returns.
984        struct ReplacedWhileBuildingClient;
985
986        impl ReplacedWhileBuildingClient {
987            fn build(
988                uri: Uri,
989                settings: Option<&AppliedRequestSettings>,
990                pool: &ConnectionPool,
991            ) -> Result<(Self, u64), TransportError> {
992                let connect = || {
993                    Ok::<_, TransportError>(PoolItem::Platform(PlatformGrpcClient::new(
994                        Channel::builder(uri.clone()).connect_lazy(),
995                    )))
996                };
997                let (_, generation) = pool.get_or_create_with_generation(
998                    PoolPrefix::Platform,
999                    &uri,
1000                    settings,
1001                    connect,
1002                )?;
1003                // What a concurrent request that timed out and reconnected
1004                // would pool.
1005                pool.put(&uri, settings, connect()?);
1006                Ok((Self, generation))
1007            }
1008        }
1009
1010        impl TransportClient for ReplacedWhileBuildingClient {
1011            fn with_uri(uri: Uri, pool: &ConnectionPool) -> Result<Self, TransportError> {
1012                Self::build(uri, None, pool).map(|(client, _)| client)
1013            }
1014
1015            fn with_uri_and_settings(
1016                uri: Uri,
1017                settings: &AppliedRequestSettings,
1018                pool: &ConnectionPool,
1019            ) -> Result<Self, TransportError> {
1020                Self::build(uri, Some(settings), pool).map(|(client, _)| client)
1021            }
1022
1023            fn with_uri_and_settings_and_generation(
1024                uri: Uri,
1025                settings: &AppliedRequestSettings,
1026                pool: &ConnectionPool,
1027            ) -> Result<(Self, u64), TransportError> {
1028                Self::build(uri, Some(settings), pool)
1029            }
1030        }
1031
1032        /// Never answers.
1033        #[derive(Clone, Debug)]
1034        struct StallOnReplacedConnection;
1035
1036        impl Mockable for StallOnReplacedConnection {}
1037
1038        impl TransportRequest for StallOnReplacedConnection {
1039            type Client = ReplacedWhileBuildingClient;
1040            type Response = Pong;
1041
1042            const SETTINGS_OVERRIDES: RequestSettings = RequestSettings::default();
1043
1044            fn method_name(&self) -> &'static str {
1045                "stall_on_replaced_connection"
1046            }
1047
1048            fn execute_transport<'c>(
1049                self,
1050                _client: &'c mut Self::Client,
1051                _settings: &AppliedRequestSettings,
1052            ) -> BoxFuture<'c, Result<Self::Response, TransportError>> {
1053                Box::pin(futures::future::pending())
1054            }
1055        }
1056
1057        #[tokio::test(start_paused = true)]
1058        async fn should_keep_a_connection_pooled_while_the_client_was_being_built() {
1059            let client = DapiClient::new(
1060                "http://127.0.0.1:10001"
1061                    .parse()
1062                    .expect("valid address list"),
1063                RequestSettings {
1064                    retries: Some(0),
1065                    ..RequestSettings::default()
1066                },
1067            );
1068
1069            let error = client
1070                .execute(StallOnReplacedConnection, RequestSettings::default())
1071                .await
1072                .expect_err("the only node never answers");
1073
1074            assert!(
1075                matches!(
1076                    &error.inner,
1077                    DapiClientError::Transport(TransportError::Grpc(status))
1078                        if status.code() == Code::DeadlineExceeded
1079                ),
1080                "expected DeadlineExceeded, got {:?}",
1081                error.inner
1082            );
1083            let uri = error.address.expect("the attempted node").uri().clone();
1084            let settings = RequestSettings::default().finalize();
1085            assert!(
1086                client
1087                    .pool
1088                    .get(PoolPrefix::Platform, &uri, Some(&settings))
1089                    .is_some(),
1090                "a connection pooled while the attempt's client was being built must stay pooled"
1091            );
1092        }
1093    }
1094}
1095
1096#[async_trait]
1097impl DapiRequestExecutor for DapiClient {
1098    /// Execute the [DapiRequest](crate::DapiRequest).
1099    async fn execute<R>(
1100        &self,
1101        request: R,
1102        settings: RequestSettings,
1103    ) -> ExecutionResult<R::Response, DapiClientError>
1104    where
1105        R: TransportRequest + Mockable,
1106        R::Response: Mockable,
1107        TransportError: Mockable,
1108    {
1109        // Every node refuses a proof of nothing, so it is refused here, without a node, the
1110        // same way; see `TransportRequest::names_nothing`.
1111        if let Some(refusal) = request.names_nothing() {
1112            return Err(ExecutionError {
1113                inner: DapiClientError::Transport(TransportError::Grpc(Status::invalid_argument(
1114                    refusal,
1115                ))),
1116                retries: 0,
1117                address: None,
1118            });
1119        }
1120
1121        // Join settings of different sources to get final version of the settings for this execution:
1122        let applied_settings = self
1123            .settings
1124            .override_by(R::SETTINGS_OVERRIDES)
1125            .override_by(settings)
1126            .finalize();
1127        #[cfg(not(target_arch = "wasm32"))]
1128        let applied_settings = applied_settings
1129            .with_ca_certificate(self.ca_certificate.clone())
1130            .with_proxy(self.proxy.clone());
1131
1132        // Save dump dir for later use
1133        #[cfg(feature = "dump")]
1134        let dump_dir = self.dump_dir.clone();
1135        #[cfg(feature = "dump")]
1136        let dump_request = request.clone();
1137
1138        let max_retries = applied_settings.retries;
1139        let retry_delay = Duration::from_millis(10);
1140
1141        let mut retries: usize = 0;
1142        // Track the last transport error for when all addresses get exhausted
1143        let mut last_transport_error: Option<TransportError> = None;
1144
1145        let result: ExecutionResult<R::Response, DapiClientError> = async {
1146            loop {
1147                // Try to get an address to initialize transport on:
1148                let Some(address) = self.address_list.get_live_address() else {
1149                    // No available addresses - wrap with last meaningful error if we have one
1150                    let error = if let Some(transport_error) = last_transport_error.take() {
1151                        tracing::debug!(
1152                            "no addresses available, returning last transport error"
1153                        );
1154                        DapiClientError::NoAvailableAddressesToRetry(Box::new(
1155                            transport_error,
1156                        ))
1157                    } else {
1158                        DapiClientError::NoAvailableAddresses
1159                    };
1160
1161                    return Err(ExecutionError {
1162                        inner: error,
1163                        retries,
1164                        address: None,
1165                    });
1166                };
1167
1168                // Rec 3 — explicit trace event so the resolved DAPI endpoint
1169                // appears in flat plain-text log output (not just the span context).
1170                tracing::trace!(
1171                    target: "dapi_client::dispatch",
1172                    ?address,
1173                    method = request.method_name(),
1174                    request_type = request.request_name(),
1175                    "dispatching request to DAPI endpoint"
1176                );
1177                tracing::trace!(
1178                    ?request,
1179                    "calling {} with {} request",
1180                    request.method_name(),
1181                    request.request_name(),
1182                );
1183
1184                let transport_request = request.clone();
1185                let response_name = request.response_name();
1186
1187                // Try to create transport client
1188                let transport_client_result = R::Client::with_uri_and_settings_and_generation(
1189                    address.uri().clone(),
1190                    &applied_settings,
1191                    &self.pool,
1192                );
1193
1194                // `pool_generation` is the pool generation of the connection
1195                // this attempt uses. A deadline eviction below keeps
1196                // connections pooled later: a concurrent request may already
1197                // have evicted this one and reconnected.
1198                let (mut transport_client, pool_generation) = match transport_client_result {
1199                    Ok(client) => client,
1200                    Err(transport_error) => {
1201                        let can_retry_error = transport_error.can_retry();
1202
1203                        // Clone error before moving it
1204                        let cloned_error = transport_error.clone();
1205
1206                        let execution_error = ExecutionError {
1207                            inner: DapiClientError::Transport(transport_error),
1208                            retries,
1209                            address: Some(address.clone()),
1210                        };
1211
1212                        update_address_ban_status::<R::Response, DapiClientError>(
1213                            &self.address_list,
1214                            &Err(execution_error.clone()),
1215                            &applied_settings,
1216                        );
1217
1218                        if can_retry_error && retries < max_retries {
1219                            // Store last transport error
1220                            last_transport_error = Some(cloned_error);
1221
1222                            retries += 1;
1223                            tracing::warn!(
1224                                error = ?execution_error,
1225                                "retrying error with sleeping {} secs",
1226                                retry_delay.as_secs_f32()
1227                            );
1228                            transport::sleep(retry_delay).await;
1229                            continue;
1230                        }
1231
1232                        return Err(execution_error);
1233                    }
1234                };
1235
1236                // Execute the transport request
1237                let attempt = transport_request
1238                    .execute_transport(&mut transport_client, &applied_settings)
1239                    .instrument(tracing::trace_span!(
1240                        "execute_request",
1241                        ?address,
1242                        settings = ?applied_settings,
1243                        method = request.method_name(),
1244                    ));
1245                // tonic enforces the `grpc-timeout` header only until the
1246                // response headers arrive; reading the body has no limit, so an
1247                // attempt over a half-open connection would never return. Bound
1248                // the whole attempt, and drop the pooled connection it used.
1249                #[cfg(not(target_arch = "wasm32"))]
1250                let result = match applied_settings.attempt_deadline() {
1251                    Some(deadline) => match tokio::time::timeout(deadline, attempt).await {
1252                        Ok(result) => result,
1253                        Err(_) => {
1254                            self.pool.remove_uri(address.uri(), pool_generation);
1255                            Err(TransportError::Grpc(Status::deadline_exceeded(format!(
1256                                "no complete response within {deadline:?}"
1257                            ))))
1258                        }
1259                    },
1260                    None => attempt.await,
1261                };
1262                #[cfg(target_arch = "wasm32")]
1263                let result = {
1264                    let _ = pool_generation;
1265                    attempt.await
1266                };
1267
1268                let execution_result = match result {
1269                    Ok(response) => {
1270                        tracing::trace!(response = ?response, "received {} response", response_name);
1271                        Ok(ExecutionResponse {
1272                            inner: response,
1273                            retries,
1274                            address: address.clone(),
1275                        })
1276                    }
1277                    Err(transport_error) => {
1278                        tracing::debug!(error = ?transport_error, "received error: {transport_error}");
1279                        Err(ExecutionError {
1280                            inner: DapiClientError::Transport(transport_error),
1281                            retries,
1282                            address: Some(address.clone()),
1283                        })
1284                    }
1285                };
1286
1287                update_address_ban_status::<R::Response, DapiClientError>(
1288                    &self.address_list,
1289                    &execution_result,
1290                    &applied_settings,
1291                );
1292
1293                match execution_result {
1294                    Ok(response) => return Ok(response),
1295                    Err(error) => {
1296                        if error.can_retry() && retries < max_retries {
1297                            // Store last transport error
1298                            if let DapiClientError::Transport(ref te) = error.inner {
1299                                last_transport_error = Some(te.clone());
1300                            }
1301
1302                            retries += 1;
1303                            tracing::warn!(
1304                                ?error,
1305                                "retrying error with sleeping {} secs",
1306                                retry_delay.as_secs_f32()
1307                            );
1308                            transport::sleep(retry_delay).await;
1309                            continue;
1310                        }
1311
1312                        return Err(error);
1313                    }
1314                }
1315            }
1316        }
1317        .instrument(tracing::info_span!("request routine"))
1318        .await;
1319
1320        if let Err(error) = &result {
1321            if !error.can_retry() {
1322                tracing::error!(?error, "request failed");
1323            }
1324        }
1325
1326        // Dump request and response to disk if dump_dir is set:
1327        #[cfg(feature = "dump")]
1328        Self::dump_request_response(&dump_request, &result, dump_dir);
1329
1330        result
1331    }
1332}