Skip to main content

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}