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}