Skip to main content

toolkit_contract/runtime/
config.rs

1//! Client and retry configuration consumed by generated REST clients.
2
3use std::time::Duration;
4
5/// Base configuration for a generated REST client.
6#[derive(Debug, Clone)]
7pub struct ClientConfig {
8    /// Base URL prefix (e.g., `https://billing.internal`).
9    /// Combined with the base path declared in the projection trait.
10    pub base_url: String,
11    /// Deadline applied to a **single** unary attempt — NOT to the whole logical
12    /// call. A `#[retryable]` method may make up to `retry.max_attempts` attempts,
13    /// so the worst-case wall-clock for a logical call is bounded by
14    /// `max_attempts × (timeout + retry.max_delay)` (the per-retry backoff is
15    /// itself clamped to [`RetryConfig::max_delay`], including a server-advised
16    /// `Retry-After`). There is deliberately no separate whole-call budget field.
17    pub timeout: Duration,
18    /// Per-**event** idle deadline for SSE streams: the maximum gap between two
19    /// received stream events before the stream is treated as timed out. A
20    /// long-lived stream is NOT bounded by [`timeout`](Self::timeout) (which
21    /// would kill a healthy slow stream); it is bounded by this larger idle
22    /// deadline instead. Defaults to 60s (> the unary default).
23    pub sse_idle_timeout: Duration,
24    /// Retry policy applied to methods marked `#[retryable]`.
25    pub retry: RetryConfig,
26    /// SSE-stream reconnect policy. By default `max_attempts: 0` — stream
27    /// failures bubble up unchanged. Set explicitly to opt into HTML5
28    /// EventSource-style `Last-Event-ID` reconnect.
29    pub sse_reconnect: ReconnectConfig,
30    /// When `true`, the generated client refuses plaintext `http://` and
31    /// requires TLS (`toolkit_http::TransportSecurity::TlsOnly`) for every
32    /// request — including the bearer-carrying `Authorization` header, which
33    /// otherwise would ride whatever scheme `base_url` uses. Defaults to
34    /// `false`, preserving the platform's existing in-mesh
35    /// service-to-service convention where plaintext HTTP inside a secured
36    /// network boundary is an accepted, deliberate choice (see
37    /// [`build_default_http_client`](crate::runtime::client::build_default_http_client)).
38    /// Set this when a resolved endpoint may cross an untrusted network.
39    pub require_tls: bool,
40}
41
42impl ClientConfig {
43    /// Create a new config with sensible defaults.
44    #[must_use]
45    pub fn new(base_url: impl Into<String>) -> Self {
46        Self {
47            base_url: base_url.into(),
48            timeout: Duration::from_secs(30),
49            sse_idle_timeout: Duration::from_mins(1),
50            retry: RetryConfig::default(),
51            sse_reconnect: ReconnectConfig::default(),
52            require_tls: false,
53        }
54    }
55
56    /// Override the per-call (unary) timeout.
57    #[must_use]
58    pub fn with_timeout(mut self, timeout: Duration) -> Self {
59        self.timeout = timeout;
60        self
61    }
62
63    /// Override the SSE per-event idle deadline (max gap between stream events).
64    #[must_use]
65    pub fn with_sse_idle_timeout(mut self, idle: Duration) -> Self {
66        self.sse_idle_timeout = idle;
67        self
68    }
69
70    /// Override the retry policy.
71    #[must_use]
72    pub fn with_retry(mut self, retry: RetryConfig) -> Self {
73        self.retry = retry;
74        self
75    }
76
77    /// Override the SSE reconnect policy. Use [`ReconnectConfig::default()`]
78    /// to disable (the default) or build a non-zero `max_attempts` policy
79    /// to enable reconnect.
80    #[must_use]
81    pub fn with_sse_reconnect(mut self, sse_reconnect: ReconnectConfig) -> Self {
82        self.sse_reconnect = sse_reconnect;
83        self
84    }
85
86    /// Require TLS (reject plaintext `http://`) for this client. See
87    /// [`Self::require_tls`].
88    #[must_use]
89    pub fn with_require_tls(mut self, require_tls: bool) -> Self {
90        self.require_tls = require_tls;
91        self
92    }
93}
94
95/// Bounded exponential-backoff retry policy with full jitter.
96#[derive(Debug, Clone)]
97pub struct RetryConfig {
98    /// Maximum number of attempts (must be at least 1).
99    pub max_attempts: u32,
100    /// Base delay before the first retry.
101    pub base_delay: Duration,
102    /// Hard cap on the delay between retries.
103    pub max_delay: Duration,
104    /// Multiplier applied between consecutive retries.
105    pub multiplier: f64,
106}
107
108impl RetryConfig {
109    /// Disable retries entirely (single attempt).
110    #[must_use]
111    pub const fn off() -> Self {
112        Self {
113            max_attempts: 1,
114            base_delay: Duration::ZERO,
115            max_delay: Duration::ZERO,
116            multiplier: 1.0,
117        }
118    }
119}
120
121impl Default for RetryConfig {
122    fn default() -> Self {
123        Self {
124            max_attempts: 3,
125            base_delay: Duration::from_millis(100),
126            max_delay: Duration::from_secs(2),
127            multiplier: 2.0,
128        }
129    }
130}
131
132/// SSE reconnect policy. The streaming client tracks the latest `id:`
133/// field seen on the wire and, on transient stream failures, re-issues
134/// the request with a `Last-Event-ID: <stored>` header so the server can
135/// resume the event sequence (per HTML5 `EventSource` spec).
136///
137/// Default is **opt-in disabled** (`max_attempts: 0`) so existing SDKs see
138/// no behaviour change.
139#[derive(Debug, Clone)]
140pub struct ReconnectConfig {
141    /// Maximum number of reconnect attempts after the initial connection.
142    /// `0` (default) disables reconnect entirely — stream errors bubble up.
143    pub max_attempts: u32,
144    /// Initial delay before the first reconnect attempt.
145    pub base_delay: Duration,
146    /// Hard cap on delay between reconnect attempts.
147    pub max_delay: Duration,
148}
149
150impl Default for ReconnectConfig {
151    fn default() -> Self {
152        Self {
153            max_attempts: 0,
154            base_delay: Duration::from_millis(500),
155            max_delay: Duration::from_secs(10),
156        }
157    }
158}
159
160impl ReconnectConfig {
161    /// Build a reconnect policy with up to `max_attempts` retries and the
162    /// supplied initial delay (capped by `max_delay`, default 10s).
163    #[must_use]
164    pub fn enabled(max_attempts: u32, base_delay: Duration) -> Self {
165        Self {
166            max_attempts,
167            base_delay,
168            max_delay: Duration::from_secs(10),
169        }
170    }
171
172    /// Override the maximum delay between reconnect attempts.
173    #[must_use]
174    pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
175        self.max_delay = max_delay;
176        self
177    }
178}
179
180#[cfg(test)]
181#[cfg_attr(coverage_nightly, coverage(off))]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn default_retry_has_three_attempts() {
187        let r = RetryConfig::default();
188        assert_eq!(r.max_attempts, 3);
189        assert!(r.base_delay > Duration::ZERO);
190    }
191
192    #[test]
193    fn off_yields_single_attempt() {
194        let r = RetryConfig::off();
195        assert_eq!(r.max_attempts, 1);
196    }
197
198    #[test]
199    fn client_config_chains_overrides() {
200        let cfg = ClientConfig::new("https://x.example")
201            .with_timeout(Duration::from_secs(5))
202            .with_retry(RetryConfig::off());
203        assert_eq!(cfg.base_url, "https://x.example");
204        assert_eq!(cfg.timeout, Duration::from_secs(5));
205        assert_eq!(cfg.retry.max_attempts, 1);
206    }
207}