Skip to main content

rs_dapi_client/
request_settings.rs

1//! DAPI client request settings processing.
2
3#[cfg(not(target_arch = "wasm32"))]
4use dapi_grpc::tonic::transport::Certificate;
5use std::time::Duration;
6
7/// Default low-level client timeout
8const DEFAULT_CONNECT_TIMEOUT: Option<Duration> = None;
9const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
10const DEFAULT_RETRIES: usize = 5;
11const DEFAULT_BAN_FAILED_ADDRESS: bool = true;
12
13/// DAPI request settings.
14///
15/// There are four levels of settings where each next level can override all previous ones:
16/// 1. Defaults for this library;
17/// 2. [crate::DapiClient] settings;
18/// 3. [crate::DapiRequest]-specific settings;
19/// 4. settings for an exact request execution call.
20#[derive(Debug, Clone, Copy, Default)]
21pub struct RequestSettings {
22    /// Timeout for establishing a connection.
23    pub connect_timeout: Option<Duration>,
24    /// Timeout for single request (soft limit).
25    ///
26    /// Note that the total maximum time of execution can exceed `(timeout + connect_timeout) * retries`
27    /// as it accounts for internal processing time between retries.
28    pub timeout: Option<Duration>,
29    /// Number of retries in case of failed requests. If max retries reached, the last error is returned.
30    /// 1 means one request and one retry in case of error, etc.
31    pub retries: Option<usize>,
32    /// Ban DAPI address if node not responded or responded with error.
33    pub ban_failed_address: Option<bool>,
34    /// Maximum gRPC response size in bytes (decoding limit).
35    pub max_decoding_message_size: Option<usize>,
36}
37
38impl RequestSettings {
39    /// Create empty [RequestSettings], which means no overrides will be applied.
40    /// Actually does the same as [Default], but it's `const`.
41    pub const fn default() -> Self {
42        RequestSettings {
43            connect_timeout: None,
44            timeout: None,
45            retries: None,
46            ban_failed_address: None,
47            max_decoding_message_size: None,
48        }
49    }
50
51    /// Combines two instances of [RequestSettings] with following rules:
52    /// 1. in case of [Some] and [None] for one field the [Some] variant will remain,
53    /// 2. in case of two [Some] variants, right hand side argument will overwrite the value.
54    pub fn override_by(self, rhs: RequestSettings) -> Self {
55        RequestSettings {
56            connect_timeout: rhs.connect_timeout.or(self.connect_timeout),
57            timeout: rhs.timeout.or(self.timeout),
58            retries: rhs.retries.or(self.retries),
59            ban_failed_address: rhs.ban_failed_address.or(self.ban_failed_address),
60            max_decoding_message_size: rhs
61                .max_decoding_message_size
62                .or(self.max_decoding_message_size),
63        }
64    }
65
66    /// Fill in settings defaults.
67    pub fn finalize(self) -> AppliedRequestSettings {
68        AppliedRequestSettings {
69            connect_timeout: self.connect_timeout.or(DEFAULT_CONNECT_TIMEOUT),
70            timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
71            retries: self.retries.unwrap_or(DEFAULT_RETRIES),
72            ban_failed_address: self
73                .ban_failed_address
74                .unwrap_or(DEFAULT_BAN_FAILED_ADDRESS),
75            max_decoding_message_size: self.max_decoding_message_size,
76            #[cfg(not(target_arch = "wasm32"))]
77            ca_certificate: None,
78        }
79    }
80}
81
82/// DAPI settings ready to use.
83///
84/// When adding a field, decide whether it affects the constructed transport
85/// client and update `connection_key` accordingly (its exhaustive
86/// destructuring will not compile until you do).
87#[derive(Debug, Clone)]
88pub struct AppliedRequestSettings {
89    /// Timeout for establishing a connection.
90    pub connect_timeout: Option<Duration>,
91    /// Timeout for a request.
92    pub timeout: Duration,
93    /// Number of retries until returning the last error.
94    pub retries: usize,
95    /// Ban DAPI address if node not responded or responded with error.
96    pub ban_failed_address: bool,
97    /// Maximum gRPC response size in bytes (decoding limit).
98    pub max_decoding_message_size: Option<usize>,
99    /// Certificate Authority certificate to use for verifying the server's certificate.
100    #[cfg(not(target_arch = "wasm32"))]
101    pub ca_certificate: Option<Certificate>,
102}
103impl AppliedRequestSettings {
104    /// Use provided CA certificate for verifying the server's certificate.
105    ///
106    /// If set to None, the system's default CA certificates will be used.
107    #[cfg(not(target_arch = "wasm32"))]
108    pub fn with_ca_certificate(mut self, ca_cert: Option<Certificate>) -> Self {
109        self.ca_certificate = ca_cert;
110        self
111    }
112
113    /// Cache key fragment for the [ConnectionPool](crate::ConnectionPool),
114    /// covering only the fields that affect the constructed transport client:
115    /// connect timeout, response decoding limit and CA certificate.
116    /// Per-request knobs (request timeout, retries, address banning) are
117    /// deliberately excluded so requests that differ only in those reuse the
118    /// same pooled connection.
119    pub(crate) fn connection_key(&self) -> String {
120        // Exhaustive destructuring: adding a settings field breaks this
121        // binding, forcing an explicit connection-affecting-or-not decision.
122        let Self {
123            #[cfg(not(target_arch = "wasm32"))]
124            connect_timeout,
125            #[cfg(target_arch = "wasm32")]
126                connect_timeout: _,
127            timeout: _,
128            retries: _,
129            ban_failed_address: _,
130            max_decoding_message_size,
131            #[cfg(not(target_arch = "wasm32"))]
132            ca_certificate,
133        } = self;
134
135        // The wasm channel builder ignores `connect_timeout`, so it does not
136        // split the key there. The decoding limit still participates because
137        // `grpc.rs` applies it to the client after channel construction.
138        #[cfg(target_arch = "wasm32")]
139        let connect_timeout = None::<Duration>;
140
141        // The full certificate bytes (hex), not a short hash: two trust
142        // anchors must never share a pool key, or a request pinned to one CA
143        // silently reuses a channel built against the other.
144        #[cfg(not(target_arch = "wasm32"))]
145        let ca_certificate = ca_certificate.as_ref().map(|cert| {
146            use std::fmt::Write;
147            let bytes = cert.as_ref();
148            let mut hex = String::with_capacity(bytes.len() * 2);
149            for byte in bytes {
150                write!(hex, "{byte:02x}").expect("writing to a String cannot fail");
151            }
152            hex
153        });
154        #[cfg(target_arch = "wasm32")]
155        let ca_certificate: Option<String> = None;
156
157        format!(
158            "connect_timeout={:?},max_decoding_message_size={:?},ca_certificate={:?}",
159            connect_timeout, max_decoding_message_size, ca_certificate
160        )
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_request_settings_override_by() {
170        let base = RequestSettings {
171            timeout: Some(Duration::from_secs(5)),
172            retries: Some(3),
173            connect_timeout: Some(Duration::from_secs(2)),
174            ban_failed_address: Some(true),
175            max_decoding_message_size: Some(1024),
176        };
177
178        // Override with partial settings
179        let override_settings = RequestSettings {
180            timeout: Some(Duration::from_secs(10)),
181            retries: None,
182            connect_timeout: None,
183            ban_failed_address: None,
184            max_decoding_message_size: None,
185        };
186
187        let result = base.override_by(override_settings);
188        assert_eq!(result.timeout, Some(Duration::from_secs(10))); // overridden
189        assert_eq!(result.retries, Some(3)); // preserved from base
190        assert_eq!(result.connect_timeout, Some(Duration::from_secs(2))); // preserved
191        assert_eq!(result.ban_failed_address, Some(true)); // preserved
192        assert_eq!(result.max_decoding_message_size, Some(1024)); // preserved
193    }
194
195    #[test]
196    fn test_request_settings_override_by_empty() {
197        let base = RequestSettings {
198            timeout: Some(Duration::from_secs(5)),
199            retries: Some(3),
200            connect_timeout: None,
201            ban_failed_address: None,
202            max_decoding_message_size: None,
203        };
204
205        let result = base.override_by(RequestSettings::default());
206        assert_eq!(result.timeout, Some(Duration::from_secs(5)));
207        assert_eq!(result.retries, Some(3));
208    }
209
210    #[test]
211    fn test_request_settings_finalize_defaults() {
212        let settings = RequestSettings::default();
213        let applied = settings.finalize();
214
215        assert_eq!(applied.connect_timeout, None);
216        assert_eq!(applied.timeout, Duration::from_secs(10));
217        assert_eq!(applied.retries, 5);
218        assert!(applied.ban_failed_address);
219        assert!(applied.max_decoding_message_size.is_none());
220    }
221
222    #[test]
223    fn test_request_settings_finalize_custom() {
224        let settings = RequestSettings {
225            connect_timeout: Some(Duration::from_secs(3)),
226            timeout: Some(Duration::from_secs(30)),
227            retries: Some(10),
228            ban_failed_address: Some(false),
229            max_decoding_message_size: Some(4096),
230        };
231
232        let applied = settings.finalize();
233        assert_eq!(applied.connect_timeout, Some(Duration::from_secs(3)));
234        assert_eq!(applied.timeout, Duration::from_secs(30));
235        assert_eq!(applied.retries, 10);
236        assert!(!applied.ban_failed_address);
237        assert_eq!(applied.max_decoding_message_size, Some(4096));
238    }
239
240    #[cfg(not(target_arch = "wasm32"))]
241    #[test]
242    fn test_applied_settings_with_ca_certificate_none() {
243        let applied = RequestSettings::default().finalize();
244        let result = applied.with_ca_certificate(None);
245        assert!(result.ca_certificate.is_none());
246    }
247
248    #[cfg(not(target_arch = "wasm32"))]
249    #[test]
250    fn test_applied_settings_with_ca_certificate_some() {
251        let applied = RequestSettings::default().finalize();
252        let cert = Certificate::from_pem("fake-pem-data");
253        let result = applied.with_ca_certificate(Some(cert));
254        assert!(result.ca_certificate.is_some());
255    }
256
257    #[test]
258    fn test_connection_key_ignores_per_request_settings() {
259        let custom = RequestSettings {
260            timeout: Some(Duration::from_secs(30)),
261            retries: Some(1),
262            ban_failed_address: Some(false),
263            ..RequestSettings::default()
264        }
265        .finalize();
266        let default = RequestSettings::default().finalize();
267
268        assert_eq!(
269            custom.connection_key(),
270            default.connection_key(),
271            "timeout/retries/banning must not split pooled connections"
272        );
273    }
274
275    #[test]
276    fn test_connection_key_differs_on_connection_settings() {
277        let default = RequestSettings::default().finalize();
278
279        let connect_timeout = RequestSettings {
280            connect_timeout: Some(Duration::from_secs(3)),
281            ..RequestSettings::default()
282        }
283        .finalize();
284        assert_ne!(default.connection_key(), connect_timeout.connection_key());
285
286        let decode_limit = RequestSettings {
287            max_decoding_message_size: Some(16 * 1024 * 1024),
288            ..RequestSettings::default()
289        }
290        .finalize();
291        assert_ne!(default.connection_key(), decode_limit.connection_key());
292    }
293
294    #[cfg(not(target_arch = "wasm32"))]
295    #[test]
296    fn test_connection_key_differs_on_ca_certificate() {
297        let default = RequestSettings::default().finalize();
298        let with_ca = RequestSettings::default()
299            .finalize()
300            .with_ca_certificate(Some(Certificate::from_pem("fake-pem-data")));
301
302        assert_ne!(default.connection_key(), with_ca.connection_key());
303
304        let with_other_ca = RequestSettings::default()
305            .finalize()
306            .with_ca_certificate(Some(Certificate::from_pem("other-pem-data")));
307        assert_ne!(with_ca.connection_key(), with_other_ca.connection_key());
308    }
309}