Skip to main content

toolkit_contract/
wiring.rs

1//! `ClientWiring` — typed config schema consumed by `#[toolkit::provides]`.
2//!
3//! Lives outside the feature-gated `runtime` module so the deserialization
4//! itself is always available: any module loaded into the host must be able
5//! to parse its wiring config regardless of which transport features its
6//! provider SDK compiled in. The actual conversion to a runtime
7//! [`ClientConfig`](crate::runtime::config::ClientConfig) is gated on
8//! `runtime-client`.
9
10use std::time::Duration;
11
12use serde::Deserialize;
13
14/// Fine-tuning knobs forwarded to the transport client when a remote
15/// transport is selected. All fields are optional — missing values fall
16/// back to the SDK defaults baked into
17/// [`ClientConfig`](crate::runtime::config::ClientConfig).
18#[derive(Clone, Debug, Default, Deserialize)]
19pub struct ClientTuning {
20    /// Per-call request deadline (e.g., `"5s"`, `"500ms"`).
21    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
22    pub timeout: Option<Duration>,
23
24    /// Override for the retry policy applied to `#[retryable]` methods.
25    #[serde(default)]
26    pub retry: Option<RetrySettings>,
27
28    /// Override for the stream reconnect policy, of any framing.
29    ///
30    /// `alias = "sse_reconnect"` keeps already-deployed config files working:
31    /// this struct has no `rename_all`, so the Rust field name *is* the JSON
32    /// key, and the field was called `sse_reconnect` before the policy covered
33    /// framings other than SSE.
34    ///
35    /// Supplying **both** `stream_reconnect` and `sse_reconnect` is rejected as
36    /// a duplicate field — the two name the same field — so a config that adds
37    /// the new key without removing the legacy one fails loudly at parse time
38    /// rather than silently honouring one and dropping the other. This holds
39    /// even though the field is `#[serde(flatten)]`-ed into [`ClientWiring`].
40    #[serde(default, alias = "sse_reconnect")]
41    pub stream_reconnect: Option<ReconnectSettings>,
42
43    /// Reject plaintext `http://` endpoints.
44    ///
45    /// Defaults to `false` (the in-mesh convention). Without this knob a
46    /// discovery-resolved client always got the default, so a consumer talking
47    /// to an endpoint outside a trusted boundary had no way to demand TLS —
48    /// while forwarding a tenant bearer token over it.
49    #[serde(default)]
50    pub require_tls: Option<bool>,
51
52    /// Max idle keep-alive connections per upstream host. Raise this to at least
53    /// the expected per-upstream request concurrency so HTTP/1.1 connections are
54    /// reused instead of churned under load. Missing keeps the SDK default
55    /// ([`ClientConfig::pool_max_idle_per_host`](crate::runtime::config::ClientConfig::pool_max_idle_per_host)).
56    ///
57    /// **REST transport only.** A `transport: grpc` wiring accepts this key but
58    /// it has no effect — the gRPC client reads only `endpoint`, `timeout` and
59    /// `require_tls` (a warning is logged at gRPC client construction).
60    #[serde(default)]
61    pub pool_max_idle_per_host: Option<usize>,
62
63    /// How long idle keep-alive connections are retained (e.g. `"90s"`, `"2m"`)
64    /// before being closed and reopened on the next request. Companion of
65    /// `pool_max_idle_per_host`; raise it above the gap between successive bursts
66    /// to a given upstream to keep connections warm. Missing keeps the SDK
67    /// default ([`ClientConfig::pool_idle_timeout`](crate::runtime::config::ClientConfig::pool_idle_timeout)).
68    ///
69    /// **REST transport only** (see `pool_max_idle_per_host`).
70    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
71    pub pool_idle_timeout: Option<Duration>,
72
73    /// Max concurrent in-flight requests through this client at once. Requests
74    /// beyond the cap are shed immediately (`HttpError::Overloaded`). Keep it at
75    /// or above `pool_max_idle_per_host` so the pool can be fully reused. Missing
76    /// keeps the SDK default
77    /// ([`ClientConfig::max_concurrent_requests`](crate::runtime::config::ClientConfig::max_concurrent_requests)).
78    ///
79    /// **REST transport only** (see `pool_max_idle_per_host`).
80    #[serde(default)]
81    pub max_concurrent_requests: Option<usize>,
82
83    /// Platform-plane credential source forwarded onto the built
84    /// [`ClientConfig`](crate::runtime::config::ClientConfig). Injected by the
85    /// runtime's proxy-wiring phase, never from config (`#[serde(skip)]`); gated
86    /// on `runtime-client` since the type lives there.
87    #[cfg(feature = "runtime-client")]
88    #[serde(skip)]
89    pub internal_token_provider: Option<crate::runtime::config::InternalTokenProvider>,
90}
91
92impl ClientTuning {
93    /// REST-only transport knobs that were explicitly set on this tuning.
94    ///
95    /// Used to warn when a non-REST transport is selected: the gRPC client reads
96    /// only `endpoint`, `timeout` and `require_tls`, so
97    /// `pool_max_idle_per_host`, `pool_idle_timeout` and
98    /// `max_concurrent_requests` on a `transport: grpc` wiring silently go
99    /// nowhere. Returns their names (empty when none are set).
100    #[must_use]
101    pub fn rest_only_knobs_set(&self) -> Vec<&'static str> {
102        let mut set = Vec::new();
103        if self.pool_max_idle_per_host.is_some() {
104            set.push("pool_max_idle_per_host");
105        }
106        if self.pool_idle_timeout.is_some() {
107            set.push("pool_idle_timeout");
108        }
109        if self.max_concurrent_requests.is_some() {
110            set.push("max_concurrent_requests");
111        }
112        set
113    }
114}
115
116/// Deserializable mirror of
117/// [`RetryConfig`](crate::runtime::config::RetryConfig). All fields optional;
118/// missing values keep the runtime default.
119#[derive(Clone, Debug, Default, Deserialize)]
120pub struct RetrySettings {
121    pub max_attempts: Option<u32>,
122    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
123    pub base_delay: Option<Duration>,
124    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
125    pub max_delay: Option<Duration>,
126    pub multiplier: Option<f64>,
127}
128
129/// Deserializable mirror of
130/// [`ReconnectConfig`](crate::runtime::config::ReconnectConfig).
131#[derive(Clone, Debug, Default, Deserialize)]
132pub struct ReconnectSettings {
133    pub max_attempts: Option<u32>,
134    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
135    pub base_delay: Option<Duration>,
136    #[serde(default, with = "toolkit_utils::humantime_serde::option")]
137    pub max_delay: Option<Duration>,
138}
139
140/// Transport choice + endpoint + tuning for one provided contract.
141///
142/// Read by `#[toolkit::provides]` from
143/// `gears.<gear>.config.client_wiring.<contract_snake>`. If the key is
144/// absent the wiring defaults to [`ClientWiring::Local`].
145#[derive(Clone, Debug, Default, Deserialize)]
146#[serde(rename_all = "lowercase", tag = "transport")]
147pub enum ClientWiring {
148    /// In-process. The provider gear's local factory is invoked.
149    #[default]
150    Local,
151    /// Generated REST client points at `endpoint`.
152    Rest {
153        endpoint: String,
154        #[serde(default, flatten)]
155        tuning: ClientTuning,
156    },
157    /// Generated gRPC client connects to `endpoint`.
158    Grpc {
159        endpoint: String,
160        #[serde(default, flatten)]
161        tuning: ClientTuning,
162    },
163}
164
165#[cfg(feature = "runtime-client")]
166impl ClientTuning {
167    /// Apply tuning overrides onto a fresh [`ClientConfig`] built from `endpoint`.
168    #[must_use]
169    pub fn apply_to(&self, endpoint: impl Into<String>) -> crate::runtime::config::ClientConfig {
170        use crate::runtime::config::{ClientConfig, ReconnectConfig, RetryConfig};
171
172        let mut cfg = ClientConfig::new(endpoint);
173        if let Some(timeout) = self.timeout {
174            cfg = cfg.with_timeout(timeout);
175        }
176        if let Some(ref r) = self.retry {
177            let base = cfg.retry.clone();
178            cfg = cfg.with_retry(RetryConfig {
179                max_attempts: r.max_attempts.unwrap_or(base.max_attempts),
180                base_delay: r.base_delay.unwrap_or(base.base_delay),
181                max_delay: r.max_delay.unwrap_or(base.max_delay),
182                multiplier: r.multiplier.unwrap_or(base.multiplier),
183            });
184        }
185        if let Some(ref s) = self.stream_reconnect {
186            let base = cfg.stream_reconnect.clone();
187            cfg = cfg.with_stream_reconnect(ReconnectConfig {
188                max_attempts: s.max_attempts.unwrap_or(base.max_attempts),
189                base_delay: s.base_delay.unwrap_or(base.base_delay),
190                max_delay: s.max_delay.unwrap_or(base.max_delay),
191                // Not exposed as wiring keys yet; inherit the base policy.
192                min_healthy_uptime: base.min_healthy_uptime,
193                max_total_reopens: base.max_total_reopens,
194            });
195        }
196        if let Some(require_tls) = self.require_tls {
197            cfg = cfg.with_require_tls(require_tls);
198        }
199        if let Some(max) = self.pool_max_idle_per_host {
200            cfg = cfg.with_pool_max_idle_per_host(max);
201        }
202        if let Some(timeout) = self.pool_idle_timeout {
203            cfg = cfg.with_pool_idle_timeout(Some(timeout));
204        }
205        if let Some(max) = self.max_concurrent_requests {
206            cfg = cfg.with_max_concurrent_requests(Some(max));
207        }
208        cfg = cfg.with_internal_token_provider(self.internal_token_provider.clone());
209        cfg
210    }
211
212    /// Attach the platform-plane credential source forwarded onto the built
213    /// [`ClientConfig`](crate::runtime::config::ClientConfig). Used by the
214    /// proxy-wiring phase to thread the process credential into a
215    /// directory-resolving (`#[toolkit::consumes]`) client.
216    #[must_use]
217    pub fn with_internal_token_provider(
218        mut self,
219        provider: Option<crate::runtime::config::InternalTokenProvider>,
220    ) -> Self {
221        self.internal_token_provider = provider;
222        self
223    }
224}