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