toolkit_contract/runtime/config.rs
1//! Client, retry, and credential configuration consumed by generated clients.
2//!
3//! [`ClientConfig`] is shared by both the generated REST client and the
4//! generated gRPC client; [`InternalTokenProvider`] is the runtime source of the
5//! platform-plane credential those clients attach on `PlatformSecurityContext`
6//! methods.
7
8use std::borrow::Cow;
9use std::sync::Arc;
10use std::time::Duration;
11
12use secrecy::SecretString;
13
14/// Outcome of resolving the process's platform-plane credential on one outbound
15/// call.
16///
17/// Three-state (rather than `Option<SecretString>`) so an attach site can tell
18/// an intentionally unauthenticated deployment (Profile 1) apart from a broken
19/// credential source — e.g. the projected token file is transiently empty.
20/// Attach helpers stay silent on [`Self::NotConfigured`] and `warn!` on
21/// [`Self::Unavailable`], never emitting the token.
22#[derive(Debug)]
23pub enum CredentialState {
24 /// No credential configured (Profile 1 / `InternalCredential::None`). Attach
25 /// nothing, silently — a legitimate deployment.
26 NotConfigured,
27 /// A credential is configured and currently available; attach it.
28 Available(SecretString),
29 /// Configured but currently unavailable (empty token file, or the background
30 /// refresh has not run yet). Attach nothing but **warn** — a broken source,
31 /// not an intentional opt-out. Carries the reason (never the token).
32 Unavailable(Cow<'static, str>),
33}
34
35/// Source of the process's **platform-plane** internal credential.
36///
37/// Generated clients attach it — as the `X-ToolKit-Internal-Token` header /
38/// metadata, **never** `Authorization` — on methods whose plane marker is
39/// `PlatformSecurityContext` (`cpt-cf-adr-two-plane-auth`). The credential comes
40/// from the runtime (the bootstrap-selected `InternalCredential`), never the
41/// contract argument.
42///
43/// Invoked on every call so a rotating credential (e.g. a projected Kubernetes
44/// `ServiceAccount` token) is always attached in its current form; it returns a
45/// [`CredentialState`] to distinguish not-configured (silent) from unavailable
46/// (warn). Because it runs per-request on an async path, the closure **must not
47/// block, do I/O, or take a contended lock** (see [`InternalTokenProvider::new`]).
48#[derive(Clone)]
49pub struct InternalTokenProvider(Arc<dyn Fn() -> CredentialState + Send + Sync>);
50
51impl InternalTokenProvider {
52 /// Build a provider whose credential is resolved by `provider` on each call
53 /// (supports rotation).
54 ///
55 /// The closure **must not block, do I/O, or take a contended lock** — it is
56 /// called on every outbound platform-plane request from an async path. See
57 /// [`InternalTokenProvider`] and use `ServiceAccountTokenReader::token_provider`
58 /// as the reference pattern for a rotating credential.
59 #[must_use]
60 pub fn new(provider: impl Fn() -> CredentialState + Send + Sync + 'static) -> Self {
61 Self(Arc::new(provider))
62 }
63
64 /// Build a provider that always yields the given static `token`.
65 ///
66 /// Suitable for a non-rotating credential (e.g. a shared secret); prefer
67 /// [`InternalTokenProvider::new`] for rotating tokens.
68 #[must_use]
69 pub fn from_token(token: SecretString) -> Self {
70 Self::new(move || CredentialState::Available(token.clone()))
71 }
72
73 /// Resolve the current credential state.
74 #[must_use]
75 pub fn current(&self) -> CredentialState {
76 (self.0)()
77 }
78
79 /// Resolve the token to attach on an outbound platform-plane call, applying
80 /// the shared attach policy so the REST and gRPC helpers behave identically:
81 /// `None`/[`NotConfigured`](CredentialState::NotConfigured) → `None` (silent),
82 /// [`Available`](CredentialState::Available) → `Some`, and
83 /// [`Unavailable`](CredentialState::Unavailable) → `None` plus a `warn!`
84 /// naming the plane and `rpc` (never the token).
85 #[must_use]
86 pub fn resolve_for_attach(provider: Option<&Self>, rpc: &str) -> Option<SecretString> {
87 match provider.map(Self::current) {
88 None | Some(CredentialState::NotConfigured) => None,
89 Some(CredentialState::Available(token)) => Some(token),
90 Some(CredentialState::Unavailable(reason)) => {
91 tracing::warn!(
92 plane = "platform",
93 rpc,
94 reason = %reason,
95 "platform-plane credential configured but currently unavailable; \
96 sending request without the internal token",
97 );
98 None
99 }
100 }
101 }
102}
103
104impl std::fmt::Debug for InternalTokenProvider {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 // Never render the credential (or even hint at its presence beyond the
107 // opaque marker) so it cannot leak through a `{:?}` sink.
108 f.write_str("InternalTokenProvider(<fn>)")
109 }
110}
111
112/// Base configuration for a generated REST client.
113#[derive(Debug, Clone)]
114pub struct ClientConfig {
115 /// Base URL prefix (e.g., `https://billing.internal`).
116 /// Combined with the base path declared in the projection trait.
117 pub base_url: String,
118 /// Deadline applied to a **single** unary attempt — NOT to the whole logical
119 /// call. A `#[retryable]` method may make up to `retry.max_attempts` attempts,
120 /// so the worst-case wall-clock for a logical call is bounded by
121 /// `max_attempts × (timeout + retry.max_delay)` (the per-retry backoff is
122 /// itself clamped to [`RetryConfig::max_delay`], including a server-advised
123 /// `Retry-After`). There is deliberately no separate whole-call budget field.
124 pub timeout: Duration,
125 /// Per-**event** idle deadline for SSE streams: the maximum gap between two
126 /// received stream events before the stream is treated as timed out. A
127 /// long-lived stream is NOT bounded by [`timeout`](Self::timeout) (which
128 /// would kill a healthy slow stream); it is bounded by this larger idle
129 /// deadline instead. Defaults to 60s (> the unary default).
130 pub sse_idle_timeout: Duration,
131 /// Retry policy applied to methods marked `#[retryable]`.
132 pub retry: RetryConfig,
133 /// SSE-stream reconnect policy. By default `max_attempts: 0` — stream
134 /// failures bubble up unchanged. Set explicitly to opt into HTML5
135 /// EventSource-style `Last-Event-ID` reconnect.
136 pub sse_reconnect: ReconnectConfig,
137 /// When `true`, the generated client refuses plaintext `http://` and
138 /// requires TLS (`toolkit_http::TransportSecurity::TlsOnly`) for every
139 /// request — including the bearer-carrying `Authorization` header, which
140 /// otherwise would ride whatever scheme `base_url` uses. Defaults to
141 /// `false`, preserving the platform's existing in-mesh
142 /// service-to-service convention where plaintext HTTP inside a secured
143 /// network boundary is an accepted, deliberate choice (see
144 /// [`build_default_http_client`](crate::runtime::client::build_default_http_client)).
145 /// Set this when a resolved endpoint may cross an untrusted network.
146 pub require_tls: bool,
147 /// Source of the platform-plane internal credential attached to methods
148 /// whose plane marker is `PlatformSecurityContext` (carried as
149 /// `X-ToolKit-Internal-Token`). `None` (the default) attaches nothing —
150 /// legitimate for Profile 1 / in-process (`InternalCredential::None`);
151 /// the requirement is enforced server-side. The bootstrap layer populates
152 /// this from the process's selected `InternalCredential`. Tenant-plane
153 /// methods (`SecurityContext`) never consult it; they forward the caller's
154 /// bearer token from the argument.
155 pub internal_token_provider: Option<InternalTokenProvider>,
156}
157
158impl ClientConfig {
159 /// Create a new config with sensible defaults.
160 #[must_use]
161 pub fn new(base_url: impl Into<String>) -> Self {
162 Self {
163 base_url: base_url.into(),
164 timeout: Duration::from_secs(30),
165 sse_idle_timeout: Duration::from_mins(1),
166 retry: RetryConfig::default(),
167 sse_reconnect: ReconnectConfig::default(),
168 require_tls: false,
169 internal_token_provider: None,
170 }
171 }
172
173 /// Override the per-call (unary) timeout.
174 #[must_use]
175 pub fn with_timeout(mut self, timeout: Duration) -> Self {
176 self.timeout = timeout;
177 self
178 }
179
180 /// Override the SSE per-event idle deadline (max gap between stream events).
181 #[must_use]
182 pub fn with_sse_idle_timeout(mut self, idle: Duration) -> Self {
183 self.sse_idle_timeout = idle;
184 self
185 }
186
187 /// Override the retry policy.
188 #[must_use]
189 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
190 self.retry = retry;
191 self
192 }
193
194 /// Override the SSE reconnect policy. Use [`ReconnectConfig::default()`]
195 /// to disable (the default) or build a non-zero `max_attempts` policy
196 /// to enable reconnect.
197 #[must_use]
198 pub fn with_sse_reconnect(mut self, sse_reconnect: ReconnectConfig) -> Self {
199 self.sse_reconnect = sse_reconnect;
200 self
201 }
202
203 /// Require TLS (reject plaintext `http://`) for this client. See
204 /// [`Self::require_tls`].
205 #[must_use]
206 pub fn with_require_tls(mut self, require_tls: bool) -> Self {
207 self.require_tls = require_tls;
208 self
209 }
210
211 /// Set (or clear) the platform-plane internal-credential provider. See
212 /// [`Self::internal_token_provider`]. Accepts either an
213 /// [`InternalTokenProvider`] or an `Option<InternalTokenProvider>`, so the
214 /// bootstrap layer can pass through whatever the process selected without a
215 /// branch.
216 #[must_use]
217 pub fn with_internal_token_provider(
218 mut self,
219 provider: impl Into<Option<InternalTokenProvider>>,
220 ) -> Self {
221 self.internal_token_provider = provider.into();
222 self
223 }
224}
225
226/// Bounded exponential-backoff retry policy with full jitter.
227#[derive(Debug, Clone)]
228pub struct RetryConfig {
229 /// Maximum number of attempts (must be at least 1).
230 pub max_attempts: u32,
231 /// Base delay before the first retry.
232 pub base_delay: Duration,
233 /// Hard cap on the delay between retries.
234 pub max_delay: Duration,
235 /// Multiplier applied between consecutive retries.
236 pub multiplier: f64,
237}
238
239impl RetryConfig {
240 /// Disable retries entirely (single attempt).
241 #[must_use]
242 pub const fn off() -> Self {
243 Self {
244 max_attempts: 1,
245 base_delay: Duration::ZERO,
246 max_delay: Duration::ZERO,
247 multiplier: 1.0,
248 }
249 }
250}
251
252impl Default for RetryConfig {
253 fn default() -> Self {
254 Self {
255 max_attempts: 3,
256 base_delay: Duration::from_millis(100),
257 max_delay: Duration::from_secs(2),
258 multiplier: 2.0,
259 }
260 }
261}
262
263/// SSE reconnect policy. The streaming client tracks the latest `id:`
264/// field seen on the wire and, on transient stream failures, re-issues
265/// the request with a `Last-Event-ID: <stored>` header so the server can
266/// resume the event sequence (per HTML5 `EventSource` spec).
267///
268/// Default is **opt-in disabled** (`max_attempts: 0`) so existing SDKs see
269/// no behaviour change.
270#[derive(Debug, Clone)]
271pub struct ReconnectConfig {
272 /// Maximum number of reconnect attempts after the initial connection.
273 /// `0` (default) disables reconnect entirely — stream errors bubble up.
274 pub max_attempts: u32,
275 /// Initial delay before the first reconnect attempt.
276 pub base_delay: Duration,
277 /// Hard cap on delay between reconnect attempts.
278 pub max_delay: Duration,
279}
280
281impl Default for ReconnectConfig {
282 fn default() -> Self {
283 Self {
284 max_attempts: 0,
285 base_delay: Duration::from_millis(500),
286 max_delay: Duration::from_secs(10),
287 }
288 }
289}
290
291impl ReconnectConfig {
292 /// Build a reconnect policy with up to `max_attempts` retries and the
293 /// supplied initial delay (capped by `max_delay`, default 10s).
294 #[must_use]
295 pub fn enabled(max_attempts: u32, base_delay: Duration) -> Self {
296 Self {
297 max_attempts,
298 base_delay,
299 max_delay: Duration::from_secs(10),
300 }
301 }
302
303 /// Override the maximum delay between reconnect attempts.
304 #[must_use]
305 pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
306 self.max_delay = max_delay;
307 self
308 }
309}
310
311#[cfg(test)]
312#[cfg_attr(coverage_nightly, coverage(off))]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn default_retry_has_three_attempts() {
318 let r = RetryConfig::default();
319 assert_eq!(r.max_attempts, 3);
320 assert!(r.base_delay > Duration::ZERO);
321 }
322
323 #[test]
324 fn off_yields_single_attempt() {
325 let r = RetryConfig::off();
326 assert_eq!(r.max_attempts, 1);
327 }
328
329 #[test]
330 fn client_config_chains_overrides() {
331 let cfg = ClientConfig::new("https://x.example")
332 .with_timeout(Duration::from_secs(5))
333 .with_retry(RetryConfig::off());
334 assert_eq!(cfg.base_url, "https://x.example");
335 assert_eq!(cfg.timeout, Duration::from_secs(5));
336 assert_eq!(cfg.retry.max_attempts, 1);
337 }
338}