Skip to main content

lightstreamer_rs/config/
mod.rs

1//! Everything a client needs to be told before it can open a session.
2//!
3//! Configuration arrives through typed, checked values — never through the
4//! environment. This is a library: reading `LS_PASSWORD` out of the process
5//! environment behind the caller's back would make the crate's behaviour
6//! depend on something the caller did not write down, and would put a secret
7//! somewhere this crate has no business looking. Credentials are supplied by
8//! the caller, are never logged, and never appear in an error.
9//!
10//! # Shape of the API
11//!
12//! ```
13//! use lightstreamer_rs::{AdapterSet, ClientConfig, Credentials, ServerAddress};
14//!
15//! let config = ClientConfig::builder(ServerAddress::try_new("https://push.lightstreamer.com")?)
16//!     .with_adapter_set(AdapterSet::try_new("DEMO")?)
17//!     .with_credentials(Credentials::anonymous())
18//!     .build()?;
19//!
20//! assert_eq!(config.address().as_str(), "https://push.lightstreamer.com");
21//! # Ok::<(), lightstreamer_rs::ConfigError>(())
22//! ```
23
24mod address;
25mod credentials;
26mod options;
27
28use std::time::Duration;
29
30pub use address::ServerAddress;
31pub use credentials::{AdapterSet, Credentials};
32pub use options::{ConnectionOptions, RetryPolicy, Transport};
33
34/// The longest any timing in this crate may be set to: 365 days.
35///
36/// A choice of this crate, and an upper bound rather than a recommendation —
37/// every sensible value is orders of magnitude below it. It exists because a
38/// duration is added to an [`Instant`](std::time::Instant) to make a deadline,
39/// and that addition is not total: past the runtime timer's own range the
40/// arithmetic overflows, and an overflowed deadline is an expired one. A
41/// timeout of [`Duration::MAX`] would then mean "give up at once" — the exact
42/// opposite of what it says. Refusing the value at configuration time is the
43/// only place that inversion can be caught while it is still a caller's
44/// mistake rather than a client that retries in a loop.
45pub const MAX_TIMING: Duration = Duration::from_secs(365 * 24 * 60 * 60);
46
47/// A configuration value that cannot be used as given.
48///
49/// Every variant names the one thing that is wrong and, where it helps, the
50/// offending value. No variant ever carries a password: a credential that
51/// reached an error message would end up in whatever log the caller writes the
52/// error to.
53#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
54#[non_exhaustive]
55pub enum ConfigError {
56    /// The server address was empty or only whitespace.
57    #[error("server address is empty")]
58    EmptyServerAddress,
59
60    /// The server address has no `scheme://` prefix.
61    ///
62    /// The scheme is required rather than guessed, because defaulting to TLS
63    /// or to plain text would each be wrong half the time and neither would be
64    /// visible to the caller.
65    #[error(
66        "server address `{address}` has no scheme: expected ws://, wss://, http:// or https://"
67    )]
68    MissingScheme {
69        /// The address as supplied.
70        address: String,
71    },
72
73    /// The server address names a scheme this crate cannot speak.
74    #[error("unsupported scheme `{scheme}`: expected ws, wss, http or https")]
75    UnsupportedScheme {
76        /// The scheme as supplied, lowercased.
77        scheme: String,
78    },
79
80    /// The server address has a scheme but no host after it.
81    #[error("server address `{address}` has no host")]
82    MissingHost {
83        /// The address as supplied.
84        address: String,
85    },
86
87    /// The server address carries a `user:password@` prefix.
88    ///
89    /// Refused rather than stripped, and deliberately reported without
90    /// quoting the address: an address that reached an error message would
91    /// take the credential in it into whatever log the error is written to.
92    /// Supply credentials with [`Credentials`] instead, where this crate
93    /// knows to redact them.
94    #[error("server address must not contain credentials; use Credentials instead")]
95    AddressHasUserinfo,
96
97    /// The host part of the server address is not a host.
98    #[error(
99        "`{host}` is not a host: an IPv6 literal must be bracketed, and no host may contain whitespace"
100    )]
101    InvalidHost {
102        /// The host as supplied.
103        host: String,
104    },
105
106    /// The port part of the server address is not a port.
107    #[error("`{port}` is not a port: expected a number from 1 to 65535")]
108    InvalidPort {
109        /// The port as supplied.
110        port: String,
111    },
112
113    /// What follows the host in the server address is not a usable base path.
114    ///
115    /// TLCP fixes the endpoint path [`docs/spec/01-foundations.md` §6.2.1], so
116    /// a query, a fragment or a `..` segment either means something this crate
117    /// would silently drop or aims the connection at a different resource.
118    #[error("`{path}` is not a base path: no query, fragment or `..` segment is allowed")]
119    InvalidAddressPath {
120        /// The path as supplied.
121        path: String,
122    },
123
124    /// The Adapter Set name was empty or only whitespace.
125    ///
126    /// To request the server's `DEFAULT` Adapter Set, leave the name unset
127    /// instead: naming the empty string is a different request, and not one a
128    /// caller means [`docs/spec/03-requests.md` §2.1].
129    #[error("adapter set name is empty; leave it unset to use the server's DEFAULT")]
130    EmptyAdapterSet,
131
132    /// The Adapter Set name contains an ASCII control character.
133    #[error("adapter set name contains a control character")]
134    AdapterSetControlCharacter,
135
136    /// A subscription named no items.
137    #[error("subscription names no items")]
138    EmptyItemGroup,
139
140    /// An item group contains an ASCII control character.
141    #[error("item group contains a control character")]
142    ItemGroupControlCharacter,
143
144    /// An item name in a list contains whitespace, which would split it into
145    /// two items once the list is joined.
146    #[error("item name `{name}` contains whitespace; item names are separated by spaces")]
147    ItemNameHasWhitespace {
148        /// The offending name.
149        name: String,
150    },
151
152    /// A subscription named no fields.
153    #[error("subscription names no fields")]
154    EmptyFieldSchema,
155
156    /// A field schema contains an ASCII control character.
157    #[error("field schema contains a control character")]
158    FieldSchemaControlCharacter,
159
160    /// A field name in a list contains whitespace, which would split it into
161    /// two fields once the list is joined.
162    #[error("field name `{name}` contains whitespace; field names are separated by spaces")]
163    FieldNameHasWhitespace {
164        /// The offending name.
165        name: String,
166    },
167
168    /// A message sequence name is not a legal identifier.
169    #[error("`{name}` is not a sequence name: expected letters, digits and underscores")]
170    InvalidSequenceName {
171        /// The name as supplied.
172        name: String,
173    },
174
175    /// A frequency limit is not a decimal number with a dot separator.
176    #[error("`{value}` is not a frequency: expected a decimal number such as `2` or `0.5`")]
177    InvalidFrequency {
178        /// The text as supplied.
179        value: String,
180    },
181
182    /// A snapshot length was asked for in a mode that does not admit one.
183    ///
184    /// `LS_snapshot` as a number of events is "admitted only if `LS_mode` is
185    /// `DISTINCT`" [`docs/spec/03-requests.md` §6.1].
186    #[error("a snapshot length is admitted only with SubscriptionMode::Distinct, not {mode}")]
187    SnapshotLengthNeedsDistinct {
188        /// The mode the subscription was built with.
189        mode: &'static str,
190    },
191
192    /// A snapshot was asked for in [`SubscriptionMode::Raw`](crate::SubscriptionMode::Raw),
193    /// which keeps no item state and so has none to send
194    /// [`docs/spec/03-requests.md` §6.1].
195    #[error("a snapshot is not available with SubscriptionMode::Raw")]
196    SnapshotNeedsAStatefulMode,
197
198    /// A maximum frequency was asked for in
199    /// [`SubscriptionMode::Raw`](crate::SubscriptionMode::Raw), which the
200    /// server does not filter [`docs/spec/03-requests.md` §6.1].
201    #[error("a maximum frequency is not applied with SubscriptionMode::Raw")]
202    FrequencyNeedsAFilteredMode,
203
204    /// A buffer size was asked for in a mode the server does not buffer.
205    ///
206    /// `LS_requested_buffer_size` is considered "only if `LS_mode` is `MERGE`
207    /// or `DISTINCT`" [`docs/spec/03-requests.md` §6.1].
208    #[error("a buffer size applies only to SubscriptionMode::Merge or ::Distinct, not {mode}")]
209    BufferSizeNeedsMergeOrDistinct {
210        /// The mode the subscription was built with.
211        mode: &'static str,
212    },
213
214    /// A buffer size was asked for alongside an unfiltered frequency, which
215    /// the server ignores [`docs/spec/03-requests.md` §6.1].
216    #[error("a buffer size is ignored with MaxFrequency::Unfiltered; set one or the other")]
217    BufferSizeWithUnfilteredFrequency,
218
219    /// A [`Message::fire_and_forget`](crate::Message::fire_and_forget) message
220    /// was placed in a sequence.
221    ///
222    /// The two are inseparable in the protocol: `LS_msg_prog` is "mandatory
223    /// whenever `LS_sequence` is specified" and a fire-and-forget message is
224    /// precisely one with no progressive [`docs/spec/03-requests.md` §12.1].
225    #[error("a fire-and-forget message cannot be placed in a sequence: it carries no progressive")]
226    SequencedMessageNeedsAProgressive,
227
228    /// A maximum wait was set on a message that is in no sequence, where the
229    /// server ignores it [`docs/spec/03-requests.md` §12.1].
230    #[error("a maximum wait applies only to a message in a sequence")]
231    MaxWaitNeedsASequence,
232
233    /// A duration that must be positive was zero.
234    #[error("`{field}` must be greater than zero")]
235    ZeroDuration {
236        /// Which knob, by the name of its builder method.
237        field: &'static str,
238    },
239
240    /// A duration is longer than this crate's timers are defined for.
241    ///
242    /// Either it does not fit the whole number of milliseconds the wire
243    /// carries, or it exceeds [`MAX_TIMING`]. Both are refused here rather
244    /// than clamped: a deadline that overflowed would become an *immediate*
245    /// one, inverting the very setting that produced it.
246    #[error("`{field}` is longer than the supported maximum of {MAX_TIMING:?}")]
247    DurationTooLarge {
248        /// Which knob, by the name of its builder method.
249        field: &'static str,
250    },
251
252    /// The reconnection ceiling is below the first reconnection delay, so the
253    /// schedule could never be honoured.
254    #[error("retry max_delay ({max:?}) is below initial_delay ({initial:?})")]
255    RetryCeilingBelowInitial {
256        /// The configured first delay.
257        initial: Duration,
258        /// The configured ceiling.
259        max: Duration,
260    },
261}
262
263/// Everything needed to open a session, checked and ready to use.
264///
265/// Build one with [`ClientConfig::builder`]; the fields are private so a
266/// configuration that failed a check cannot exist. Hand it to
267/// [`Client::connect`](crate::Client::connect).
268///
269/// # Examples
270///
271/// ```
272/// use std::time::Duration;
273/// use lightstreamer_rs::{
274///     AdapterSet, ClientConfig, ConnectionOptions, Credentials, ServerAddress,
275/// };
276///
277/// let config = ClientConfig::builder(ServerAddress::try_new("wss://push.example.com")?)
278///     .with_adapter_set(AdapterSet::try_new("MY_ADAPTERS")?)
279///     .with_credentials(Credentials::new("alice", "hunter2"))
280///     .with_options(ConnectionOptions::default().with_open_timeout(Duration::from_secs(5)))
281///     .build()?;
282///
283/// assert_eq!(config.adapter_set().map(AdapterSet::as_str), Some("MY_ADAPTERS"));
284/// # Ok::<(), lightstreamer_rs::ConfigError>(())
285/// ```
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ClientConfig {
288    address: ServerAddress,
289    adapter_set: Option<AdapterSet>,
290    credentials: Credentials,
291    transport: Transport,
292    options: ConnectionOptions,
293}
294
295impl ClientConfig {
296    /// Starts building a configuration for a server.
297    ///
298    /// The address is the only value with no sensible default, so it is taken
299    /// here rather than left to be forgotten.
300    #[must_use = "builders do nothing unless .build() is called"]
301    pub fn builder(address: ServerAddress) -> ClientConfigBuilder {
302        ClientConfigBuilder {
303            address,
304            adapter_set: None,
305            credentials: Credentials::anonymous(),
306            transport: Transport::default(),
307            options: ConnectionOptions::default(),
308        }
309    }
310
311    /// The server this configuration points at.
312    #[must_use]
313    #[inline]
314    pub const fn address(&self) -> &ServerAddress {
315        &self.address
316    }
317
318    /// The Adapter Set that will serve the session, or `None` to let the
319    /// server use its `DEFAULT`.
320    #[must_use]
321    #[inline]
322    pub const fn adapter_set(&self) -> Option<&AdapterSet> {
323        self.adapter_set.as_ref()
324    }
325
326    /// The credentials the session will be created with.
327    #[must_use]
328    #[inline]
329    pub const fn credentials(&self) -> &Credentials {
330        &self.credentials
331    }
332
333    /// The transport that will carry the session.
334    #[must_use]
335    #[inline]
336    pub const fn transport(&self) -> Transport {
337        self.transport
338    }
339
340    /// The timeouts, limits and reconnection policy.
341    #[must_use]
342    #[inline]
343    pub const fn options(&self) -> &ConnectionOptions {
344        &self.options
345    }
346
347    /// Takes the configuration apart for the layer that will run it.
348    ///
349    /// This module is a **leaf**: it validates what the caller wrote and knows
350    /// nothing about the state machine, the wire, or the transports — it cannot
351    /// even name them. The translation from this vocabulary into theirs
352    /// therefore lives on the other side of the boundary, in
353    /// `SessionOptions::from_client_config`, and this is the one accessor it
354    /// needs.
355    pub(crate) fn into_parts(self) -> (Option<AdapterSet>, Credentials, ConnectionOptions) {
356        (self.adapter_set, self.credentials, self.options)
357    }
358}
359
360/// Assembles a [`ClientConfig`], checking it once at the end.
361///
362/// The checks that involve more than one value — a reconnection ceiling below
363/// its own first delay, for instance — cannot be made one setter at a time, so
364/// [`ClientConfigBuilder::build`] is where all of them happen and where the
365/// typed error comes from.
366#[derive(Debug, Clone)]
367#[must_use = "builders do nothing unless .build() is called"]
368pub struct ClientConfigBuilder {
369    address: ServerAddress,
370    adapter_set: Option<AdapterSet>,
371    credentials: Credentials,
372    transport: Transport,
373    options: ConnectionOptions,
374}
375
376impl ClientConfigBuilder {
377    /// Names the Adapter Set that will serve the session.
378    ///
379    /// Left unset, the server uses an Adapter Set named `DEFAULT`
380    /// [`docs/spec/03-requests.md` §2.1].
381    #[must_use = "builders do nothing unless .build() is called"]
382    pub fn with_adapter_set(mut self, adapter_set: AdapterSet) -> Self {
383        self.adapter_set = Some(adapter_set);
384        self
385    }
386
387    /// Supplies the credentials the session is created with.
388    ///
389    /// Defaults to [`Credentials::anonymous`].
390    #[must_use = "builders do nothing unless .build() is called"]
391    pub fn with_credentials(mut self, credentials: Credentials) -> Self {
392        self.credentials = credentials;
393        self
394    }
395
396    /// Forces a particular transport.
397    ///
398    /// Defaults to [`Transport::WebSocket`]. The two HTTP variants —
399    /// [`Transport::HttpStreaming`] and [`Transport::HttpPolling`] — are also
400    /// implemented, for a network path that mangles WebSocket upgrades.
401    #[must_use = "builders do nothing unless .build() is called"]
402    pub const fn with_transport(mut self, transport: Transport) -> Self {
403        self.transport = transport;
404        self
405    }
406
407    /// Sets the timeouts, limits and reconnection policy.
408    #[must_use = "builders do nothing unless .build() is called"]
409    pub fn with_options(mut self, options: ConnectionOptions) -> Self {
410        self.options = options;
411        self
412    }
413
414    /// Checks everything and produces the configuration.
415    ///
416    /// # Errors
417    ///
418    /// - [`ConfigError::ZeroDuration`] if `open_timeout`, or a keepalive hint
419    ///   or inactivity commitment that was set, is zero, or if the first retry
420    ///   delay is zero. A zero `open_timeout` would abandon every attempt
421    ///   before it started; a zero retry delay would reconnect in a hot loop.
422    /// - [`ConfigError::DurationTooLarge`] if any duration exceeds
423    ///   [`MAX_TIMING`], or if a value that travels as a whole number of
424    ///   milliseconds does not fit one. Every timing is checked, including the
425    ///   keepalive slack and both retry delays: an overflowed deadline is an
426    ///   expired one, so a duration too large to add to an instant would
427    ///   silently become the opposite of what it says.
428    /// - [`ConfigError::RetryCeilingBelowInitial`] if the reconnection ceiling
429    ///   is below the first delay.
430    ///
431    /// A zero keepalive *slack* is accepted: it means "allow the server not
432    /// one millisecond past the interval it promised", which is aggressive but
433    /// coherent.
434    ///
435    /// # Examples
436    ///
437    /// ```
438    /// use std::time::Duration;
439    /// use lightstreamer_rs::{
440    ///     ClientConfig, ConfigError, ConnectionOptions, ServerAddress,
441    /// };
442    ///
443    /// let rejected = ClientConfig::builder(ServerAddress::try_new("wss://push.example.com")?)
444    ///     .with_options(ConnectionOptions::default().with_open_timeout(Duration::ZERO))
445    ///     .build();
446    ///
447    /// assert!(matches!(rejected, Err(ConfigError::ZeroDuration { field: "open_timeout" })));
448    /// # Ok::<(), ConfigError>(())
449    /// ```
450    pub fn build(self) -> Result<ClientConfig, ConfigError> {
451        require_positive("open_timeout", self.options.open_timeout())?;
452        require_within_maximum("open_timeout", self.options.open_timeout())?;
453        require_within_maximum("keepalive_slack", self.options.keepalive_slack())?;
454
455        if let Some(hint) = self.options.keepalive_hint() {
456            require_positive("keepalive_hint", hint)?;
457            require_within_maximum("keepalive_hint", hint)?;
458            require_millis("keepalive_hint", hint)?;
459        }
460        if let Some(commitment) = self.options.inactivity_commitment() {
461            require_positive("inactivity_commitment", commitment)?;
462            require_within_maximum("inactivity_commitment", commitment)?;
463            require_millis("inactivity_commitment", commitment)?;
464        }
465
466        let retry = self.options.retry();
467        require_positive("retry.initial_delay", retry.initial_delay())?;
468        require_within_maximum("retry.initial_delay", retry.initial_delay())?;
469        require_within_maximum("retry.max_delay", retry.max_delay())?;
470        if retry.max_delay() < retry.initial_delay() {
471            return Err(ConfigError::RetryCeilingBelowInitial {
472                initial: retry.initial_delay(),
473                max: retry.max_delay(),
474            });
475        }
476
477        Ok(ClientConfig {
478            address: self.address,
479            adapter_set: self.adapter_set,
480            credentials: self.credentials,
481            transport: self.transport,
482            options: self.options,
483        })
484    }
485}
486
487/// Rejects a zero duration where a positive one is required.
488#[cold]
489#[inline(never)]
490fn zero_duration(field: &'static str) -> ConfigError {
491    ConfigError::ZeroDuration { field }
492}
493
494/// Checks that a duration is greater than zero.
495fn require_positive(field: &'static str, value: Duration) -> Result<(), ConfigError> {
496    if value.is_zero() {
497        return Err(zero_duration(field));
498    }
499    Ok(())
500}
501
502/// Checks that a duration is one this crate's timers are defined for.
503fn require_within_maximum(field: &'static str, value: Duration) -> Result<(), ConfigError> {
504    if value > MAX_TIMING {
505        return Err(ConfigError::DurationTooLarge { field });
506    }
507    Ok(())
508}
509
510/// Checks that a duration fits the whole number of milliseconds the wire
511/// carries.
512fn require_millis(field: &'static str, value: Duration) -> Result<(), ConfigError> {
513    u64::try_from(value.as_millis())
514        .map(|_| ())
515        .map_err(|_| ConfigError::DurationTooLarge { field })
516}
517
518#[cfg(test)]
519mod tests {
520
521    use super::*;
522
523    fn address() -> ServerAddress {
524        match ServerAddress::try_new("wss://push.example.com") {
525            Ok(address) => address,
526            Err(error) => unreachable!("the fixture address is valid: {error}"),
527        }
528    }
529
530    #[test]
531    fn test_config_builds_with_only_an_address() {
532        match ClientConfig::builder(address()).build() {
533            Ok(config) => {
534                assert_eq!(config.adapter_set(), None);
535                assert_eq!(config.transport(), Transport::WebSocket);
536                assert_eq!(config.credentials().user(), None);
537            }
538            Err(error) => panic!("a minimal configuration was rejected: {error}"),
539        }
540    }
541
542    #[test]
543    fn test_config_keeps_what_it_was_given() {
544        let adapters = match AdapterSet::try_new("DEMO") {
545            Ok(set) => set,
546            Err(error) => unreachable!("the fixture adapter set is valid: {error}"),
547        };
548        let built = ClientConfig::builder(address())
549            .with_adapter_set(adapters)
550            .with_credentials(Credentials::new("alice", "hunter2"))
551            .with_options(ConnectionOptions::default().with_send_sync(false))
552            .build();
553
554        match built {
555            Ok(config) => {
556                assert_eq!(config.adapter_set().map(AdapterSet::as_str), Some("DEMO"));
557                assert_eq!(config.credentials().user(), Some("alice"));
558                assert!(config.credentials().has_password());
559                assert!(!config.options().send_sync());
560            }
561            Err(error) => panic!("rejected: {error}"),
562        }
563    }
564
565    #[test]
566    fn test_config_rejects_a_zero_open_timeout() {
567        let built = ClientConfig::builder(address())
568            .with_options(ConnectionOptions::default().with_open_timeout(Duration::ZERO))
569            .build();
570        assert!(matches!(
571            built,
572            Err(ConfigError::ZeroDuration {
573                field: "open_timeout"
574            })
575        ));
576    }
577
578    #[test]
579    fn test_config_rejects_a_zero_keepalive_hint() {
580        let built = ClientConfig::builder(address())
581            .with_options(ConnectionOptions::default().with_keepalive_hint(Some(Duration::ZERO)))
582            .build();
583        assert!(matches!(
584            built,
585            Err(ConfigError::ZeroDuration {
586                field: "keepalive_hint"
587            })
588        ));
589    }
590
591    #[test]
592    fn test_config_rejects_a_zero_inactivity_commitment() {
593        let built = ClientConfig::builder(address())
594            .with_options(
595                ConnectionOptions::default().with_inactivity_commitment(Some(Duration::ZERO)),
596            )
597            .build();
598        assert!(matches!(
599            built,
600            Err(ConfigError::ZeroDuration {
601                field: "inactivity_commitment"
602            })
603        ));
604    }
605
606    #[test]
607    fn test_config_rejects_a_duration_that_cannot_be_expressed_in_millis() {
608        let built = ClientConfig::builder(address())
609            .with_options(ConnectionOptions::default().with_keepalive_hint(Some(Duration::MAX)))
610            .build();
611        assert!(matches!(
612            built,
613            Err(ConfigError::DurationTooLarge {
614                field: "keepalive_hint"
615            })
616        ));
617    }
618
619    #[test]
620    fn test_config_rejects_a_zero_first_retry_delay() {
621        let retry = RetryPolicy::default().with_initial_delay(Duration::ZERO);
622        let built = ClientConfig::builder(address())
623            .with_options(ConnectionOptions::default().with_retry(retry))
624            .build();
625        assert!(matches!(
626            built,
627            Err(ConfigError::ZeroDuration {
628                field: "retry.initial_delay"
629            })
630        ));
631    }
632
633    #[test]
634    fn test_config_rejects_a_retry_ceiling_below_its_first_delay() {
635        let retry = RetryPolicy::default()
636            .with_initial_delay(Duration::from_secs(10))
637            .with_max_delay(Duration::from_secs(1));
638        let built = ClientConfig::builder(address())
639            .with_options(ConnectionOptions::default().with_retry(retry))
640            .build();
641        assert!(matches!(
642            built,
643            Err(ConfigError::RetryCeilingBelowInitial { .. })
644        ));
645    }
646
647    #[test]
648    fn test_config_accepts_an_equal_retry_ceiling_and_first_delay() {
649        let retry = RetryPolicy::default()
650            .with_initial_delay(Duration::from_secs(2))
651            .with_max_delay(Duration::from_secs(2));
652        let built = ClientConfig::builder(address())
653            .with_options(ConnectionOptions::default().with_retry(retry))
654            .build();
655        assert!(built.is_ok());
656    }
657
658    // -----------------------------------------------------------------------
659    // A-08: no timing may be large enough to overflow a deadline
660    // -----------------------------------------------------------------------
661
662    /// Applies one duration to every timing knob in turn.
663    fn with_each_timing(value: Duration) -> Vec<(&'static str, Result<ClientConfig, ConfigError>)> {
664        let retry_initial = RetryPolicy::default()
665            .with_initial_delay(value)
666            .with_max_delay(value);
667        // The ceiling is checked against the first delay too, so that one is
668        // pinned at the smallest legal value while the ceiling varies.
669        let retry_max = RetryPolicy::default()
670            .with_initial_delay(Duration::from_nanos(1))
671            .with_max_delay(value);
672        vec![
673            (
674                "open_timeout",
675                ClientConfig::builder(address())
676                    .with_options(ConnectionOptions::default().with_open_timeout(value))
677                    .build(),
678            ),
679            (
680                "keepalive_slack",
681                ClientConfig::builder(address())
682                    .with_options(ConnectionOptions::default().with_keepalive_slack(value))
683                    .build(),
684            ),
685            (
686                "keepalive_hint",
687                ClientConfig::builder(address())
688                    .with_options(ConnectionOptions::default().with_keepalive_hint(Some(value)))
689                    .build(),
690            ),
691            (
692                "inactivity_commitment",
693                ClientConfig::builder(address())
694                    .with_options(
695                        ConnectionOptions::default().with_inactivity_commitment(Some(value)),
696                    )
697                    .build(),
698            ),
699            (
700                "retry.initial_delay",
701                ClientConfig::builder(address())
702                    .with_options(ConnectionOptions::default().with_retry(retry_initial))
703                    .build(),
704            ),
705            (
706                "retry.max_delay",
707                ClientConfig::builder(address())
708                    .with_options(ConnectionOptions::default().with_retry(retry_max))
709                    .build(),
710            ),
711        ]
712    }
713
714    #[test]
715    fn test_config_accepts_every_timing_at_the_supported_maximum() {
716        for (field, built) in with_each_timing(MAX_TIMING) {
717            assert!(built.is_ok(), "{field} was rejected at the maximum");
718        }
719    }
720
721    #[test]
722    fn test_config_rejects_every_timing_one_step_past_the_maximum() {
723        let over = match MAX_TIMING.checked_add(Duration::from_nanos(1)) {
724            Some(over) => over,
725            None => Duration::MAX,
726        };
727        for (field, built) in with_each_timing(over) {
728            assert!(
729                matches!(built, Err(ConfigError::DurationTooLarge { field: named }) if named == field),
730                "{field} was accepted one step past the maximum"
731            );
732        }
733    }
734
735    #[test]
736    fn test_config_rejects_every_timing_at_duration_max() {
737        // The value that would otherwise overflow the deadline arithmetic and
738        // become an *immediate* one.
739        for (field, built) in with_each_timing(Duration::MAX) {
740            assert!(
741                matches!(built, Err(ConfigError::DurationTooLarge { field: named }) if named == field),
742                "{field} accepted Duration::MAX"
743            );
744        }
745    }
746
747    #[test]
748    fn test_config_accepts_every_timing_at_its_smallest_usable_value() {
749        // One nanosecond is aggressive but coherent everywhere it is allowed;
750        // zero is separately refused where it would invert behaviour.
751        for (field, built) in with_each_timing(Duration::from_nanos(1)) {
752            assert!(built.is_ok(), "{field} was rejected at one nanosecond");
753        }
754    }
755
756    #[test]
757    fn test_config_accepts_a_zero_keepalive_slack() {
758        let built = ClientConfig::builder(address())
759            .with_options(ConnectionOptions::default().with_keepalive_slack(Duration::ZERO))
760            .build();
761        assert!(built.is_ok());
762    }
763
764    #[test]
765    fn test_config_accepts_unlimited_retries() {
766        let retry = RetryPolicy::default().with_max_attempts(None);
767        let built = ClientConfig::builder(address())
768            .with_options(ConnectionOptions::default().with_retry(retry))
769            .build();
770        assert!(built.is_ok());
771    }
772}