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 a single request attempt.
25    ///
26    /// It is sent to the server as the `grpc-timeout` header. On native targets
27    /// it also bounds the whole attempt on the client: an attempt still running
28    /// after `timeout + connect_timeout` fails with `DeadlineExceeded` and is
29    /// retried like any other retryable error. For unary RPCs the attempt runs
30    /// from dispatch through the response body and trailers; for streaming
31    /// RPCs it ends when the response headers arrive, so consuming the
32    /// returned stream is not bounded by it. Zero disables both limits.
33    ///
34    /// Note that the total maximum time of execution can exceed `(timeout + connect_timeout) * retries`
35    /// as it accounts for internal processing time between retries.
36    pub timeout: Option<Duration>,
37    /// Number of retries in case of failed requests. If max retries reached, the last error is returned.
38    /// 1 means one request and one retry in case of error, etc.
39    pub retries: Option<usize>,
40    /// Ban DAPI address if node not responded or responded with error.
41    pub ban_failed_address: Option<bool>,
42    /// Maximum gRPC response size in bytes (decoding limit).
43    pub max_decoding_message_size: Option<usize>,
44}
45
46impl RequestSettings {
47    /// Create empty [RequestSettings], which means no overrides will be applied.
48    /// Actually does the same as [Default], but it's `const`.
49    pub const fn default() -> Self {
50        RequestSettings {
51            connect_timeout: None,
52            timeout: None,
53            retries: None,
54            ban_failed_address: None,
55            max_decoding_message_size: None,
56        }
57    }
58
59    /// Combines two instances of [RequestSettings] with following rules:
60    /// 1. in case of [Some] and [None] for one field the [Some] variant will remain,
61    /// 2. in case of two [Some] variants, right hand side argument will overwrite the value.
62    pub fn override_by(self, rhs: RequestSettings) -> Self {
63        RequestSettings {
64            connect_timeout: rhs.connect_timeout.or(self.connect_timeout),
65            timeout: rhs.timeout.or(self.timeout),
66            retries: rhs.retries.or(self.retries),
67            ban_failed_address: rhs.ban_failed_address.or(self.ban_failed_address),
68            max_decoding_message_size: rhs
69                .max_decoding_message_size
70                .or(self.max_decoding_message_size),
71        }
72    }
73
74    /// Fill in settings defaults.
75    pub fn finalize(self) -> AppliedRequestSettings {
76        AppliedRequestSettings {
77            connect_timeout: self.connect_timeout.or(DEFAULT_CONNECT_TIMEOUT),
78            timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
79            retries: self.retries.unwrap_or(DEFAULT_RETRIES),
80            ban_failed_address: self
81                .ban_failed_address
82                .unwrap_or(DEFAULT_BAN_FAILED_ADDRESS),
83            max_decoding_message_size: self.max_decoding_message_size,
84            #[cfg(not(target_arch = "wasm32"))]
85            ca_certificate: None,
86        }
87    }
88}
89
90/// DAPI settings ready to use.
91///
92/// When adding a field, decide whether it affects the constructed transport
93/// client and update `connection_key` accordingly (its exhaustive
94/// destructuring will not compile until you do).
95#[derive(Debug, Clone)]
96pub struct AppliedRequestSettings {
97    /// Timeout for establishing a connection.
98    pub connect_timeout: Option<Duration>,
99    /// Timeout for a single request attempt; see [RequestSettings::timeout].
100    pub timeout: Duration,
101    /// Number of retries until returning the last error.
102    pub retries: usize,
103    /// Ban DAPI address if node not responded or responded with error.
104    pub ban_failed_address: bool,
105    /// Maximum gRPC response size in bytes (decoding limit).
106    pub max_decoding_message_size: Option<usize>,
107    /// Certificate Authority certificate to use for verifying the server's certificate.
108    #[cfg(not(target_arch = "wasm32"))]
109    pub ca_certificate: Option<Certificate>,
110}
111impl AppliedRequestSettings {
112    /// Use provided CA certificate for verifying the server's certificate.
113    ///
114    /// If set to None, the system's default CA certificates will be used.
115    #[cfg(not(target_arch = "wasm32"))]
116    pub fn with_ca_certificate(mut self, ca_cert: Option<Certificate>) -> Self {
117        self.ca_certificate = ca_cert;
118        self
119    }
120
121    /// Upper bound for one request attempt: `timeout` plus `connect_timeout`,
122    /// the bound documented on [RequestSettings::timeout]. A unary attempt
123    /// covers dispatch through the response body and trailers; a streaming
124    /// attempt ends when the response headers arrive. `None` when `timeout`
125    /// is zero, which means "no limit" (the transport then omits the
126    /// `grpc-timeout` header as well).
127    pub fn attempt_deadline(&self) -> Option<Duration> {
128        if self.timeout.is_zero() {
129            return None;
130        }
131        Some(
132            self.timeout
133                .saturating_add(self.connect_timeout.unwrap_or_default()),
134        )
135    }
136
137    /// Cache key fragment for the [ConnectionPool](crate::ConnectionPool),
138    /// covering only the fields that affect the constructed transport client:
139    /// connect timeout, response decoding limit and CA certificate.
140    /// Per-request knobs (request timeout, retries, address banning) are
141    /// deliberately excluded so requests that differ only in those reuse the
142    /// same pooled connection.
143    pub(crate) fn connection_key(&self) -> String {
144        // Exhaustive destructuring: adding a settings field breaks this
145        // binding, forcing an explicit connection-affecting-or-not decision.
146        let Self {
147            #[cfg(not(target_arch = "wasm32"))]
148            connect_timeout,
149            #[cfg(target_arch = "wasm32")]
150                connect_timeout: _,
151            timeout: _,
152            retries: _,
153            ban_failed_address: _,
154            max_decoding_message_size,
155            #[cfg(not(target_arch = "wasm32"))]
156            ca_certificate,
157        } = self;
158
159        // The wasm channel builder ignores `connect_timeout`, so it does not
160        // split the key there. The decoding limit still participates because
161        // `grpc.rs` applies it to the client after channel construction.
162        #[cfg(target_arch = "wasm32")]
163        let connect_timeout = None::<Duration>;
164
165        // The full certificate bytes (hex), not a short hash: two trust
166        // anchors must never share a pool key, or a request pinned to one CA
167        // silently reuses a channel built against the other.
168        #[cfg(not(target_arch = "wasm32"))]
169        let ca_certificate = ca_certificate.as_ref().map(|cert| {
170            use std::fmt::Write;
171            let bytes = cert.as_ref();
172            let mut hex = String::with_capacity(bytes.len() * 2);
173            for byte in bytes {
174                write!(hex, "{byte:02x}").expect("writing to a String cannot fail");
175            }
176            hex
177        });
178        #[cfg(target_arch = "wasm32")]
179        let ca_certificate: Option<String> = None;
180
181        format!(
182            "connect_timeout={:?},max_decoding_message_size={:?},ca_certificate={:?}",
183            connect_timeout, max_decoding_message_size, ca_certificate
184        )
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_request_settings_override_by() {
194        let base = RequestSettings {
195            timeout: Some(Duration::from_secs(5)),
196            retries: Some(3),
197            connect_timeout: Some(Duration::from_secs(2)),
198            ban_failed_address: Some(true),
199            max_decoding_message_size: Some(1024),
200        };
201
202        // Override with partial settings
203        let override_settings = RequestSettings {
204            timeout: Some(Duration::from_secs(10)),
205            retries: None,
206            connect_timeout: None,
207            ban_failed_address: None,
208            max_decoding_message_size: None,
209        };
210
211        let result = base.override_by(override_settings);
212        assert_eq!(result.timeout, Some(Duration::from_secs(10))); // overridden
213        assert_eq!(result.retries, Some(3)); // preserved from base
214        assert_eq!(result.connect_timeout, Some(Duration::from_secs(2))); // preserved
215        assert_eq!(result.ban_failed_address, Some(true)); // preserved
216        assert_eq!(result.max_decoding_message_size, Some(1024)); // preserved
217    }
218
219    #[test]
220    fn test_request_settings_override_by_empty() {
221        let base = RequestSettings {
222            timeout: Some(Duration::from_secs(5)),
223            retries: Some(3),
224            connect_timeout: None,
225            ban_failed_address: None,
226            max_decoding_message_size: None,
227        };
228
229        let result = base.override_by(RequestSettings::default());
230        assert_eq!(result.timeout, Some(Duration::from_secs(5)));
231        assert_eq!(result.retries, Some(3));
232    }
233
234    #[test]
235    fn test_request_settings_finalize_defaults() {
236        let settings = RequestSettings::default();
237        let applied = settings.finalize();
238
239        assert_eq!(applied.connect_timeout, None);
240        assert_eq!(applied.timeout, Duration::from_secs(10));
241        assert_eq!(applied.retries, 5);
242        assert!(applied.ban_failed_address);
243        assert!(applied.max_decoding_message_size.is_none());
244    }
245
246    #[test]
247    fn test_request_settings_finalize_custom() {
248        let settings = RequestSettings {
249            connect_timeout: Some(Duration::from_secs(3)),
250            timeout: Some(Duration::from_secs(30)),
251            retries: Some(10),
252            ban_failed_address: Some(false),
253            max_decoding_message_size: Some(4096),
254        };
255
256        let applied = settings.finalize();
257        assert_eq!(applied.connect_timeout, Some(Duration::from_secs(3)));
258        assert_eq!(applied.timeout, Duration::from_secs(30));
259        assert_eq!(applied.retries, 10);
260        assert!(!applied.ban_failed_address);
261        assert_eq!(applied.max_decoding_message_size, Some(4096));
262    }
263
264    #[cfg(not(target_arch = "wasm32"))]
265    #[test]
266    fn test_applied_settings_with_ca_certificate_none() {
267        let applied = RequestSettings::default().finalize();
268        let result = applied.with_ca_certificate(None);
269        assert!(result.ca_certificate.is_none());
270    }
271
272    #[cfg(not(target_arch = "wasm32"))]
273    #[test]
274    fn test_applied_settings_with_ca_certificate_some() {
275        let applied = RequestSettings::default().finalize();
276        let cert = Certificate::from_pem("fake-pem-data");
277        let result = applied.with_ca_certificate(Some(cert));
278        assert!(result.ca_certificate.is_some());
279    }
280
281    #[test]
282    fn should_bound_attempt_by_timeout_plus_connect_timeout() {
283        let applied = RequestSettings {
284            timeout: Some(Duration::from_secs(10)),
285            connect_timeout: Some(Duration::from_secs(3)),
286            ..RequestSettings::default()
287        }
288        .finalize();
289        assert_eq!(applied.attempt_deadline(), Some(Duration::from_secs(13)));
290
291        let default = RequestSettings::default().finalize();
292        assert_eq!(default.attempt_deadline(), Some(Duration::from_secs(10)));
293    }
294
295    #[test]
296    fn should_not_bound_attempt_when_timeout_is_zero() {
297        let applied = RequestSettings {
298            timeout: Some(Duration::ZERO),
299            connect_timeout: Some(Duration::from_secs(3)),
300            ..RequestSettings::default()
301        }
302        .finalize();
303        assert_eq!(applied.attempt_deadline(), None);
304    }
305
306    #[test]
307    fn should_saturate_attempt_deadline_instead_of_overflowing() {
308        let applied = RequestSettings {
309            timeout: Some(Duration::MAX),
310            connect_timeout: Some(Duration::from_secs(1)),
311            ..RequestSettings::default()
312        }
313        .finalize();
314        assert_eq!(applied.attempt_deadline(), Some(Duration::MAX));
315    }
316
317    #[test]
318    fn test_connection_key_ignores_per_request_settings() {
319        let custom = RequestSettings {
320            timeout: Some(Duration::from_secs(30)),
321            retries: Some(1),
322            ban_failed_address: Some(false),
323            ..RequestSettings::default()
324        }
325        .finalize();
326        let default = RequestSettings::default().finalize();
327
328        assert_eq!(
329            custom.connection_key(),
330            default.connection_key(),
331            "timeout/retries/banning must not split pooled connections"
332        );
333    }
334
335    #[test]
336    fn test_connection_key_differs_on_connection_settings() {
337        let default = RequestSettings::default().finalize();
338
339        let connect_timeout = RequestSettings {
340            connect_timeout: Some(Duration::from_secs(3)),
341            ..RequestSettings::default()
342        }
343        .finalize();
344        assert_ne!(default.connection_key(), connect_timeout.connection_key());
345
346        let decode_limit = RequestSettings {
347            max_decoding_message_size: Some(16 * 1024 * 1024),
348            ..RequestSettings::default()
349        }
350        .finalize();
351        assert_ne!(default.connection_key(), decode_limit.connection_key());
352    }
353
354    #[cfg(not(target_arch = "wasm32"))]
355    #[test]
356    fn test_connection_key_differs_on_ca_certificate() {
357        let default = RequestSettings::default().finalize();
358        let with_ca = RequestSettings::default()
359            .finalize()
360            .with_ca_certificate(Some(Certificate::from_pem("fake-pem-data")));
361
362        assert_ne!(default.connection_key(), with_ca.connection_key());
363
364        let with_other_ca = RequestSettings::default()
365            .finalize()
366            .with_ca_certificate(Some(Certificate::from_pem("other-pem-data")));
367        assert_ne!(with_ca.connection_key(), with_other_ca.connection_key());
368    }
369}