Skip to main content

rs_dapi_client/
request_settings.rs

1//! DAPI client request settings processing.
2
3#[cfg(not(target_arch = "wasm32"))]
4use crate::transport::Socks5Proxy;
5#[cfg(not(target_arch = "wasm32"))]
6use dapi_grpc::tonic::transport::Certificate;
7use std::time::Duration;
8
9/// Default low-level client timeout
10const DEFAULT_CONNECT_TIMEOUT: Option<Duration> = None;
11const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
12const DEFAULT_RETRIES: usize = 5;
13const DEFAULT_BAN_FAILED_ADDRESS: bool = true;
14
15/// DAPI request settings.
16///
17/// There are four levels of settings where each next level can override all previous ones:
18/// 1. Defaults for this library;
19/// 2. [crate::DapiClient] settings;
20/// 3. [crate::DapiRequest]-specific settings;
21/// 4. settings for an exact request execution call.
22#[derive(Debug, Clone, Copy, Default)]
23pub struct RequestSettings {
24    /// Timeout for establishing a connection.
25    pub connect_timeout: Option<Duration>,
26    /// Timeout for a single request attempt.
27    ///
28    /// It is sent to the server as the `grpc-timeout` header. On native targets
29    /// it also bounds the whole attempt on the client: an attempt still running
30    /// after `timeout + connect_timeout` fails with `DeadlineExceeded` and is
31    /// retried like any other retryable error (through a proxy without a
32    /// `connect_timeout`, the proxy's 20-second connect default counts as the
33    /// `connect_timeout` here). For unary RPCs the attempt runs
34    /// from dispatch through the response body and trailers; for streaming
35    /// RPCs it ends when the response headers arrive, so consuming the
36    /// returned stream is not bounded by it. Zero disables both limits.
37    ///
38    /// Note that the total maximum time of execution can exceed `(timeout + connect_timeout) * retries`
39    /// as it accounts for internal processing time between retries.
40    pub timeout: Option<Duration>,
41    /// Number of retries in case of failed requests. If max retries reached, the last error is returned.
42    /// 1 means one request and one retry in case of error, etc.
43    pub retries: Option<usize>,
44    /// Ban DAPI address if node not responded or responded with error.
45    pub ban_failed_address: Option<bool>,
46    /// Maximum gRPC response size in bytes (decoding limit).
47    pub max_decoding_message_size: Option<usize>,
48}
49
50impl RequestSettings {
51    /// Create empty [RequestSettings], which means no overrides will be applied.
52    /// Actually does the same as [Default], but it's `const`.
53    pub const fn default() -> Self {
54        RequestSettings {
55            connect_timeout: None,
56            timeout: None,
57            retries: None,
58            ban_failed_address: None,
59            max_decoding_message_size: None,
60        }
61    }
62
63    /// Combines two instances of [RequestSettings] with following rules:
64    /// 1. in case of [Some] and [None] for one field the [Some] variant will remain,
65    /// 2. in case of two [Some] variants, right hand side argument will overwrite the value.
66    pub fn override_by(self, rhs: RequestSettings) -> Self {
67        RequestSettings {
68            connect_timeout: rhs.connect_timeout.or(self.connect_timeout),
69            timeout: rhs.timeout.or(self.timeout),
70            retries: rhs.retries.or(self.retries),
71            ban_failed_address: rhs.ban_failed_address.or(self.ban_failed_address),
72            max_decoding_message_size: rhs
73                .max_decoding_message_size
74                .or(self.max_decoding_message_size),
75        }
76    }
77
78    /// Fill in settings defaults.
79    pub fn finalize(self) -> AppliedRequestSettings {
80        AppliedRequestSettings {
81            connect_timeout: self.connect_timeout.or(DEFAULT_CONNECT_TIMEOUT),
82            timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
83            retries: self.retries.unwrap_or(DEFAULT_RETRIES),
84            ban_failed_address: self
85                .ban_failed_address
86                .unwrap_or(DEFAULT_BAN_FAILED_ADDRESS),
87            max_decoding_message_size: self.max_decoding_message_size,
88            #[cfg(not(target_arch = "wasm32"))]
89            ca_certificate: None,
90            #[cfg(not(target_arch = "wasm32"))]
91            proxy: None,
92        }
93    }
94}
95
96/// DAPI settings ready to use.
97///
98/// When adding a field, decide whether it affects the constructed transport
99/// client and update `connection_key` accordingly (its exhaustive
100/// destructuring will not compile until you do).
101#[derive(Debug, Clone)]
102pub struct AppliedRequestSettings {
103    /// Timeout for establishing a connection.
104    pub connect_timeout: Option<Duration>,
105    /// Timeout for a single request attempt; see [RequestSettings::timeout].
106    pub timeout: Duration,
107    /// Number of retries until returning the last error.
108    pub retries: usize,
109    /// Ban DAPI address if node not responded or responded with error.
110    pub ban_failed_address: bool,
111    /// Maximum gRPC response size in bytes (decoding limit).
112    pub max_decoding_message_size: Option<usize>,
113    /// Certificate Authority certificate to use for verifying the server's certificate.
114    #[cfg(not(target_arch = "wasm32"))]
115    pub ca_certificate: Option<Certificate>,
116    /// SOCKS5 proxy every connection is tunnelled through.
117    #[cfg(not(target_arch = "wasm32"))]
118    pub proxy: Option<Socks5Proxy>,
119}
120impl AppliedRequestSettings {
121    /// Use provided CA certificate for verifying the server's certificate.
122    ///
123    /// If set to None, the system's default CA certificates will be used.
124    #[cfg(not(target_arch = "wasm32"))]
125    pub fn with_ca_certificate(mut self, ca_cert: Option<Certificate>) -> Self {
126        self.ca_certificate = ca_cert;
127        self
128    }
129
130    /// Upper bound for one request attempt: `timeout` plus `connect_timeout`,
131    /// the bound documented on [RequestSettings::timeout]. A unary attempt
132    /// covers dispatch through the response body and trailers; a streaming
133    /// attempt ends when the response headers arrive. `None` when `timeout`
134    /// is zero, which means "no limit" (the transport then omits the
135    /// `grpc-timeout` header as well).
136    pub fn attempt_deadline(&self) -> Option<Duration> {
137        if self.timeout.is_zero() {
138            return None;
139        }
140        Some(
141            self.timeout
142                .saturating_add(self.effective_connect_timeout().unwrap_or_default()),
143        )
144    }
145
146    /// The connect budget the transport applies: `connect_timeout`, or,
147    /// through a proxy without one, the proxy default (a proxy that accepts
148    /// the connection and never answers would otherwise hang the request).
149    pub(crate) fn effective_connect_timeout(&self) -> Option<Duration> {
150        #[cfg(not(target_arch = "wasm32"))]
151        if self.connect_timeout.is_none() && self.proxy.is_some() {
152            return Some(crate::transport::PROXY_CONNECT_TIMEOUT);
153        }
154        self.connect_timeout
155    }
156
157    /// Tunnel every connection through the given SOCKS5 proxy.
158    ///
159    /// If set to None, connections are made directly.
160    #[cfg(not(target_arch = "wasm32"))]
161    pub fn with_proxy(mut self, proxy: Option<Socks5Proxy>) -> Self {
162        self.proxy = proxy;
163        self
164    }
165
166    /// Cache key fragment for the [ConnectionPool](crate::ConnectionPool),
167    /// covering only the fields that affect the constructed transport client:
168    /// connect timeout, response decoding limit, CA certificate and proxy.
169    /// Per-request knobs (request timeout, retries, address banning) are
170    /// deliberately excluded so requests that differ only in those reuse the
171    /// same pooled connection.
172    pub(crate) fn connection_key(&self) -> String {
173        // Exhaustive destructuring: adding a settings field breaks this
174        // binding, forcing an explicit connection-affecting-or-not decision.
175        let Self {
176            #[cfg(not(target_arch = "wasm32"))]
177            connect_timeout,
178            #[cfg(target_arch = "wasm32")]
179                connect_timeout: _,
180            timeout: _,
181            retries: _,
182            ban_failed_address: _,
183            max_decoding_message_size,
184            #[cfg(not(target_arch = "wasm32"))]
185            ca_certificate,
186            #[cfg(not(target_arch = "wasm32"))]
187            proxy,
188        } = self;
189
190        // The wasm channel builder ignores `connect_timeout`, so it does not
191        // split the key there. The decoding limit still participates because
192        // `grpc.rs` applies it to the client after channel construction.
193        #[cfg(target_arch = "wasm32")]
194        let connect_timeout = None::<Duration>;
195
196        // The full certificate bytes (hex), not a short hash: two trust
197        // anchors must never share a pool key, or a request pinned to one CA
198        // silently reuses a channel built against the other.
199        #[cfg(not(target_arch = "wasm32"))]
200        let ca_certificate = ca_certificate.as_ref().map(|cert| {
201            use std::fmt::Write;
202            let bytes = cert.as_ref();
203            let mut hex = String::with_capacity(bytes.len() * 2);
204            for byte in bytes {
205                write!(hex, "{byte:02x}").expect("writing to a String cannot fail");
206            }
207            hex
208        });
209        #[cfg(target_arch = "wasm32")]
210        let ca_certificate: Option<String> = None;
211
212        // A proxied and a direct channel to one node never share a key.
213        #[cfg(not(target_arch = "wasm32"))]
214        let proxy = proxy.as_ref().map(Socks5Proxy::connection_key);
215        #[cfg(target_arch = "wasm32")]
216        let proxy: Option<String> = None;
217
218        format!(
219            "connect_timeout={:?},max_decoding_message_size={:?},ca_certificate={:?},proxy={:?}",
220            connect_timeout, max_decoding_message_size, ca_certificate, proxy
221        )
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_request_settings_override_by() {
231        let base = RequestSettings {
232            timeout: Some(Duration::from_secs(5)),
233            retries: Some(3),
234            connect_timeout: Some(Duration::from_secs(2)),
235            ban_failed_address: Some(true),
236            max_decoding_message_size: Some(1024),
237        };
238
239        // Override with partial settings
240        let override_settings = RequestSettings {
241            timeout: Some(Duration::from_secs(10)),
242            retries: None,
243            connect_timeout: None,
244            ban_failed_address: None,
245            max_decoding_message_size: None,
246        };
247
248        let result = base.override_by(override_settings);
249        assert_eq!(result.timeout, Some(Duration::from_secs(10))); // overridden
250        assert_eq!(result.retries, Some(3)); // preserved from base
251        assert_eq!(result.connect_timeout, Some(Duration::from_secs(2))); // preserved
252        assert_eq!(result.ban_failed_address, Some(true)); // preserved
253        assert_eq!(result.max_decoding_message_size, Some(1024)); // preserved
254    }
255
256    #[test]
257    fn test_request_settings_override_by_empty() {
258        let base = RequestSettings {
259            timeout: Some(Duration::from_secs(5)),
260            retries: Some(3),
261            connect_timeout: None,
262            ban_failed_address: None,
263            max_decoding_message_size: None,
264        };
265
266        let result = base.override_by(RequestSettings::default());
267        assert_eq!(result.timeout, Some(Duration::from_secs(5)));
268        assert_eq!(result.retries, Some(3));
269    }
270
271    #[test]
272    fn test_request_settings_finalize_defaults() {
273        let settings = RequestSettings::default();
274        let applied = settings.finalize();
275
276        assert_eq!(applied.connect_timeout, None);
277        assert_eq!(applied.timeout, Duration::from_secs(10));
278        assert_eq!(applied.retries, 5);
279        assert!(applied.ban_failed_address);
280        assert!(applied.max_decoding_message_size.is_none());
281    }
282
283    #[test]
284    fn test_request_settings_finalize_custom() {
285        let settings = RequestSettings {
286            connect_timeout: Some(Duration::from_secs(3)),
287            timeout: Some(Duration::from_secs(30)),
288            retries: Some(10),
289            ban_failed_address: Some(false),
290            max_decoding_message_size: Some(4096),
291        };
292
293        let applied = settings.finalize();
294        assert_eq!(applied.connect_timeout, Some(Duration::from_secs(3)));
295        assert_eq!(applied.timeout, Duration::from_secs(30));
296        assert_eq!(applied.retries, 10);
297        assert!(!applied.ban_failed_address);
298        assert_eq!(applied.max_decoding_message_size, Some(4096));
299    }
300
301    #[cfg(not(target_arch = "wasm32"))]
302    #[test]
303    fn test_applied_settings_with_ca_certificate_none() {
304        let applied = RequestSettings::default().finalize();
305        let result = applied.with_ca_certificate(None);
306        assert!(result.ca_certificate.is_none());
307    }
308
309    #[cfg(not(target_arch = "wasm32"))]
310    #[test]
311    fn test_applied_settings_with_ca_certificate_some() {
312        let applied = RequestSettings::default().finalize();
313        let cert = Certificate::from_pem("fake-pem-data");
314        let result = applied.with_ca_certificate(Some(cert));
315        assert!(result.ca_certificate.is_some());
316    }
317
318    #[test]
319    fn should_bound_attempt_by_timeout_plus_connect_timeout() {
320        let applied = RequestSettings {
321            timeout: Some(Duration::from_secs(10)),
322            connect_timeout: Some(Duration::from_secs(3)),
323            ..RequestSettings::default()
324        }
325        .finalize();
326        assert_eq!(applied.attempt_deadline(), Some(Duration::from_secs(13)));
327
328        let default = RequestSettings::default().finalize();
329        assert_eq!(default.attempt_deadline(), Some(Duration::from_secs(10)));
330    }
331
332    #[cfg(not(target_arch = "wasm32"))]
333    #[test]
334    fn should_count_the_proxy_connect_default_in_the_attempt_deadline() {
335        use crate::transport::{ProxyEndpoint, Socks5Auth};
336
337        let proxied = RequestSettings::default()
338            .finalize()
339            .with_proxy(Some(Socks5Proxy {
340                endpoint: ProxyEndpoint::Tcp("127.0.0.1:9050".parse().expect("address")),
341                auth: Socks5Auth::None,
342            }));
343        assert_eq!(
344            proxied.effective_connect_timeout(),
345            Some(crate::transport::PROXY_CONNECT_TIMEOUT)
346        );
347        assert_eq!(
348            proxied.attempt_deadline(),
349            Some(DEFAULT_TIMEOUT + crate::transport::PROXY_CONNECT_TIMEOUT)
350        );
351
352        let explicit = RequestSettings {
353            connect_timeout: Some(Duration::from_secs(3)),
354            ..RequestSettings::default()
355        }
356        .finalize()
357        .with_proxy(proxied.proxy.clone());
358        assert_eq!(
359            explicit.effective_connect_timeout(),
360            Some(Duration::from_secs(3))
361        );
362
363        let direct = RequestSettings::default().finalize();
364        assert_eq!(direct.effective_connect_timeout(), None);
365    }
366
367    #[test]
368    fn should_not_bound_attempt_when_timeout_is_zero() {
369        let applied = RequestSettings {
370            timeout: Some(Duration::ZERO),
371            connect_timeout: Some(Duration::from_secs(3)),
372            ..RequestSettings::default()
373        }
374        .finalize();
375        assert_eq!(applied.attempt_deadline(), None);
376    }
377
378    #[test]
379    fn should_saturate_attempt_deadline_instead_of_overflowing() {
380        let applied = RequestSettings {
381            timeout: Some(Duration::MAX),
382            connect_timeout: Some(Duration::from_secs(1)),
383            ..RequestSettings::default()
384        }
385        .finalize();
386        assert_eq!(applied.attempt_deadline(), Some(Duration::MAX));
387    }
388
389    #[test]
390    fn test_connection_key_ignores_per_request_settings() {
391        let custom = RequestSettings {
392            timeout: Some(Duration::from_secs(30)),
393            retries: Some(1),
394            ban_failed_address: Some(false),
395            ..RequestSettings::default()
396        }
397        .finalize();
398        let default = RequestSettings::default().finalize();
399
400        assert_eq!(
401            custom.connection_key(),
402            default.connection_key(),
403            "timeout/retries/banning must not split pooled connections"
404        );
405    }
406
407    #[test]
408    fn test_connection_key_differs_on_connection_settings() {
409        let default = RequestSettings::default().finalize();
410
411        let connect_timeout = RequestSettings {
412            connect_timeout: Some(Duration::from_secs(3)),
413            ..RequestSettings::default()
414        }
415        .finalize();
416        assert_ne!(default.connection_key(), connect_timeout.connection_key());
417
418        let decode_limit = RequestSettings {
419            max_decoding_message_size: Some(16 * 1024 * 1024),
420            ..RequestSettings::default()
421        }
422        .finalize();
423        assert_ne!(default.connection_key(), decode_limit.connection_key());
424    }
425
426    #[cfg(not(target_arch = "wasm32"))]
427    #[test]
428    fn test_connection_key_differs_on_ca_certificate() {
429        let default = RequestSettings::default().finalize();
430        let with_ca = RequestSettings::default()
431            .finalize()
432            .with_ca_certificate(Some(Certificate::from_pem("fake-pem-data")));
433
434        assert_ne!(default.connection_key(), with_ca.connection_key());
435
436        let with_other_ca = RequestSettings::default()
437            .finalize()
438            .with_ca_certificate(Some(Certificate::from_pem("other-pem-data")));
439        assert_ne!(with_ca.connection_key(), with_other_ca.connection_key());
440    }
441
442    #[cfg(not(target_arch = "wasm32"))]
443    #[test]
444    fn test_connection_key_differs_on_proxy() {
445        use crate::transport::{ProxyEndpoint, Socks5Auth};
446
447        let with_proxy = |auth| {
448            RequestSettings::default()
449                .finalize()
450                .with_proxy(Some(Socks5Proxy {
451                    endpoint: ProxyEndpoint::Tcp("127.0.0.1:9050".parse().expect("address")),
452                    auth,
453                }))
454                .connection_key()
455        };
456        let password = |password: &str| Socks5Auth::Password {
457            username: "alice".to_string(),
458            password: password.to_string(),
459        };
460        let direct = RequestSettings::default().finalize().connection_key();
461
462        assert_ne!(direct, with_proxy(Socks5Auth::None));
463        assert_ne!(
464            with_proxy(Socks5Auth::None),
465            with_proxy(Socks5Auth::RandomPerConnection)
466        );
467        assert_eq!(with_proxy(password("one")), with_proxy(password("one")));
468        assert_ne!(with_proxy(password("one")), with_proxy(password("two")));
469        assert!(!with_proxy(password("secret")).contains("secret"));
470    }
471}