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}