agentd/intel/client.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Intelligence client — endpoint *list* selection + one round-trip. RFC 0006 + RFC 0018.
3//!
4//! The transport is **HTTPS** (target-vision pivot; plaintext `http://` is a
5//! loopback-only dev carve-out) — each `AGENTD_INTELLIGENCE` list element is an
6//! `https://` URL; the wire is HTTP/1.1 (the gateway/provider speaks
7//! OpenAI-compatible `/chat/completions`). One request opens one connection
8//! (`Connection: close`) — simple and robust; pooling is a non-goal (RFC 0018 §10).
9//!
10//! RFC 0018 makes the channel **resilient**: `--intelligence` is an ordered list
11//! (primary + fallbacks); `complete()` drives the list through the sticky-primary
12//! failover policy ([`super::failover`]) with a per-endpoint health record +
13//! circuit breaker ([`super::health`]). A single-element list is byte-for-byte RFC
14//! 0006 behaviour — the failover machinery is inert with one endpoint. The
15//! wire/adapter/JSON path is UNCHANGED; only endpoint *selection* wraps it.
16
17use crate::net::http::{Stream, Url};
18use crate::wire::intel::{Request, Response};
19use std::cell::RefCell;
20use std::fmt;
21use std::time::Duration;
22
23use super::endpoints::EndpointList;
24use super::{anthropic, bedrock, failover, openai};
25
26/// Which in-binary adapter speaks to the endpoint. OpenAI-compatible is the
27/// canonical default; anthropic and Bedrock Converse (RFC 0031 §8 — native
28/// Bedrock) are the other in-binary dialects. Anything else lives behind a
29/// gateway (RFC 0006 §two-adapters).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Provider {
32 OpenAiCompatible,
33 Anthropic,
34 /// Amazon Bedrock Converse — the model id rides the URL path, not the body,
35 /// and auth is SigV4 (an `intelligence.auth: {kind: aws}`), not a bearer.
36 Bedrock,
37}
38
39impl Provider {
40 pub(super) fn default_path(self) -> &'static str {
41 match self {
42 Provider::OpenAiCompatible => openai::DEFAULT_PATH,
43 Provider::Anthropic => anthropic::DEFAULT_PATH,
44 Provider::Bedrock => bedrock::DEFAULT_PATH,
45 }
46 }
47
48 /// Map the config `intelligence.dialect` selector to a provider. `None`/empty
49 /// ⇒ the OpenAI-compatible default; an unknown value ⇒ `None` (the caller
50 /// keeps the default — validation rejects it earlier at the config layer).
51 pub fn from_dialect(dialect: Option<&str>) -> Option<Provider> {
52 match dialect.map(str::trim).filter(|s| !s.is_empty()) {
53 None | Some("openai") | Some("openai-compatible") => Some(Provider::OpenAiCompatible),
54 Some("anthropic") => Some(Provider::Anthropic),
55 Some("bedrock") => Some(Provider::Bedrock),
56 Some(_) => None,
57 }
58 }
59
60 /// The effective request path for THIS request. Fixed for OpenAI/Anthropic
61 /// (the configured/default path); Bedrock puts the URI-encoded model id in
62 /// the path — `/model/{modelId}/converse` — so the signer and the wire share
63 /// one dynamic target (RFC 0031 §8).
64 pub(super) fn request_path(self, configured: &str, req: &Request) -> String {
65 match self {
66 Provider::Bedrock => bedrock::converse_path(&req.model),
67 _ => configured.to_string(),
68 }
69 }
70}
71
72#[derive(Debug)]
73pub enum IntelError {
74 /// Transport / connection failure (fatal infra → exit 4, RFC 0011).
75 Transport(std::io::Error),
76 /// Non-2xx HTTP status from the endpoint.
77 Http(u16, String),
78 /// Malformed response body.
79 Parse(String),
80 /// A transport this build doesn't support (e.g. https without `tls`).
81 Unsupported(String),
82 /// Every endpoint in the list is down/broken after the bounded failover
83 /// sweep (RFC 0018 §6). The boxed cause is the last failover-class error
84 /// seen. Maps to the same fatal-infra class as `Transport` → exit 4 on
85 /// `once`; a loop/reactive daemon backs off rather than crashing.
86 AllEndpointsDown(Option<Box<IntelError>>),
87}
88
89impl fmt::Display for IntelError {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 match self {
92 IntelError::Transport(e) => write!(f, "intelligence transport error: {e}"),
93 IntelError::Http(code, body) => write!(f, "intelligence HTTP {code}: {body}"),
94 IntelError::Parse(m) => write!(f, "{m}"),
95 IntelError::Unsupported(m) => write!(f, "{m}"),
96 IntelError::AllEndpointsDown(cause) => match cause {
97 Some(e) => write!(f, "all intelligence endpoints down (last error: {e})"),
98 None => write!(f, "all intelligence endpoints down"),
99 },
100 }
101 }
102}
103impl std::error::Error for IntelError {}
104
105impl From<std::io::Error> for IntelError {
106 fn from(e: std::io::Error) -> Self {
107 IntelError::Transport(e)
108 }
109}
110
111/// A resolved intelligence client over an ordered endpoint list (RFC 0018 §3).
112pub struct IntelClient {
113 /// The endpoint list + sticky-primary cursor + per-endpoint health/breaker.
114 /// Behind a `RefCell` so `complete(&self)` can advance the cursor / record
115 /// health without forcing a `&mut` through the loop's call sites (the
116 /// per-subagent client is single-threaded; this is interior mutability, not
117 /// sharing). RFC 0018 §5.2 will swap the whole `IntelClient` on hot reload.
118 list: RefCell<EndpointList>,
119 timeout: Duration,
120 /// The run's trace id; when set, every completion carries a `traceparent`
121 /// header so the LLM call joins the run's distributed trace (RFC 0010).
122 trace_id: Option<String>,
123 /// All-endpoints-down backoff policy (RFC 0018 §6). `None` (the default,
124 /// `once`-mode) means a single sweep: all-down returns immediately and the
125 /// caller maps it to exit 4. `Some(policy)` (loop/reactive daemons) re-runs
126 /// the sweep with bounded jittered backoff so a transient host-model roll
127 /// recovers without the daemon crashing — it resumes the instant any endpoint
128 /// half-opens healthy.
129 alldown: Option<AllDownPolicy>,
130 /// Edge-triggered all-down reachability reporter (RFC 0018 §6). The model loop
131 /// runs in a CHILD process that owns this breaker state; the supervisor has no
132 /// LLM and no live view of it. When set, `complete()` invokes this ONCE on each
133 /// transition of the list's all-down state — on **entering** all-down (every
134 /// breaker open / sweep exhausted) and on **recovering** (any endpoint usable)
135 /// — so the child can report `AgentMsg::IntelHealth` upward. Edge-triggered (a
136 /// cheap `Cell<bool>` compare), so the no-transition steady state pays nothing
137 /// past one bool check. `IntelHealthReport` carries transport+index ONLY (no
138 /// URL/secret). The data path (selection/failover/wire) is UNCHANGED — this is
139 /// a pure additive upward report. Default `None` (one-shot / no supervisor).
140 health_reporter: Option<Box<dyn Fn(IntelHealthReport)>>,
141 /// Last all-down state observed by the reporter, so we only fire on a change.
142 last_all_down: std::cell::Cell<bool>,
143}
144
145/// The edge-triggered intelligence-reachability report a child emits upward (RFC
146/// 0018 §6). `active` is the bounded `(index, transport-scheme)` of the serving
147/// endpoint — transport + index ONLY, NEVER a URL/cid/host or credential (RFC 0012
148/// §3.7). `None` on entering all-down (nothing is serving).
149pub struct IntelHealthReport {
150 pub all_down: bool,
151 pub active: Option<(usize, &'static str)>,
152}
153
154/// Bounded, jittered all-down backoff (RFC 0018 §6 / §4.2). A daemon re-arms the
155/// sweep up to `max_retries` times, sleeping `base × 2^n` (capped at `max`) with
156/// per-attempt jitter, before surfacing the terminal all-down.
157#[derive(Debug, Clone, Copy)]
158pub struct AllDownPolicy {
159 pub max_retries: u32,
160 pub base: Duration,
161 pub max: Duration,
162}
163
164impl Default for AllDownPolicy {
165 fn default() -> AllDownPolicy {
166 // The §4.2 default: 1s..30s jittered.
167 AllDownPolicy {
168 max_retries: 8,
169 base: Duration::from_secs(1),
170 max: Duration::from_secs(30),
171 }
172 }
173}
174
175/// Per-endpoint dial transport, owned by [`super::endpoints`]. Intelligence is
176/// **HTTPS-only** (target-vision pivot, Phase 1): one TCP shape, `tls: true` in
177/// production; `tls: false` exists solely for the loopback dev/test carve-out
178/// (the built-in mock LLM) and is rejected for non-loopback hosts at
179/// [`resolve`] — so the enum-of-transports (unix/vsock) is gone, not gated.
180#[derive(Debug)]
181pub enum Transport {
182 Tcp { host: String, port: u16, tls: bool },
183}
184
185impl IntelClient {
186 /// Build from explicit parts (the subagent path — the spawn payload, not CLI
187 /// `Config`). `uri` is the RFC 0018 endpoint *list* (a single element is RFC
188 /// 0006). `default_token` is endpoint 1's resolved credential when its env
189 /// override is unset; later endpoints resolve their own `_<N>` token.
190 pub fn from_parts(uri: &str, default_token: Option<String>) -> Result<IntelClient, IntelError> {
191 let list = EndpointList::parse(uri, default_token)?;
192 Ok(IntelClient {
193 list: RefCell::new(list),
194 // Generous per-call ceiling; the run deadline is the real bound.
195 timeout: Duration::from_secs(120),
196 trace_id: None,
197 alldown: None,
198 health_reporter: None,
199 last_all_down: std::cell::Cell::new(false),
200 })
201 }
202
203 /// Attach the configured `intelligence.headers` (RFC 0031), applied to every
204 /// endpoint dial. Builder-style; call before use. Empty = the legacy path.
205 pub fn with_headers(self, headers: Vec<(String, String)>) -> IntelClient {
206 if !headers.is_empty() {
207 self.list.borrow_mut().set_extra_headers(headers);
208 }
209 self
210 }
211
212 /// Attach a per-request signer (RFC 0031: AWS SigV4), applied to every dial.
213 /// Builder-style; call before use.
214 pub fn with_signer(
215 self,
216 signer: Option<std::sync::Arc<dyn ::mcp::http::RequestSigner>>,
217 ) -> IntelClient {
218 if let Some(s) = signer {
219 self.list.borrow_mut().set_signer(s);
220 }
221 self
222 }
223
224 /// Select the wire dialect (RFC 0031 §8 — `intelligence.dialect`), applied to
225 /// every endpoint. `None` keeps the OpenAI-compatible default. Builder-style;
226 /// call before use.
227 pub fn with_dialect(self, dialect: Option<&str>) -> IntelClient {
228 if let Some(p) = Provider::from_dialect(dialect)
229 && p != Provider::OpenAiCompatible
230 {
231 self.list.borrow_mut().set_provider(p);
232 }
233 self
234 }
235
236 /// Install the edge-triggered all-down reachability reporter (RFC 0018 §6): the
237 /// child wires this to send an `AgentMsg::IntelHealth` up to the supervisor on
238 /// each all-down ENTER/EXIT transition. Idempotent on the steady state — the
239 /// callback fires only on a change of the list's all-down state, never per call.
240 /// Adds NOTHING to the data path; a client without it behaves exactly as before.
241 pub fn set_health_reporter(&mut self, reporter: Box<dyn Fn(IntelHealthReport)>) {
242 self.health_reporter = Some(reporter);
243 }
244
245 /// Stamp the run's trace id so each completion carries a `traceparent`
246 /// header (the LLM call joins the run's distributed trace, RFC 0010).
247 pub fn set_trace_id(&mut self, trace_id: Option<String>) {
248 self.trace_id = trace_id;
249 }
250
251 /// Enable the all-endpoints-down backoff (RFC 0018 §6) for a long-lived
252 /// `loop`/`reactive` daemon: on all-down, re-arm the failover sweep with
253 /// bounded jittered backoff instead of surfacing the terminal immediately.
254 /// `once`-mode leaves this unset (a single sweep → exit 4). The run deadline
255 /// still bounds the total wait (RFC 0011 §5 — backoff does not extend it).
256 pub fn enable_alldown_backoff(&mut self, policy: AllDownPolicy) {
257 self.alldown = Some(policy);
258 }
259
260 /// The number of configured endpoints (1 == RFC 0006).
261 pub fn endpoint_count(&self) -> usize {
262 self.list.borrow().len()
263 }
264
265 /// The run's trace id, if stamped (RFC 0010). Read on a hot-swap (RFC 0018
266 /// §5.2) to re-stamp the rebuilt client so it keeps joining the run's trace.
267 pub fn trace_id(&self) -> Option<&str> {
268 self.trace_id.as_deref()
269 }
270
271 /// Whether the all-endpoints-down backoff is enabled (a long-lived
272 /// loop/reactive daemon). Read on a hot-swap (RFC 0018 §5.2) so the rebuilt
273 /// client preserves the daemon's resilience posture across a repoint.
274 pub fn alldown_enabled(&self) -> bool {
275 self.alldown.is_some()
276 }
277
278 /// One completion round-trip, driven through the failover policy (RFC 0018
279 /// §3.3). The call-site signature is unchanged from RFC 0006, so the whole
280 /// exit-code path (`IntelError` → `LoopAbort::Intel` → exit 4) is intact.
281 ///
282 /// When all endpoints are down (§6) and the all-down backoff is enabled
283 /// (loop/reactive daemons), the sweep is re-armed with bounded jittered
284 /// backoff so a transient host-model roll recovers without crashing the
285 /// daemon; `once`-mode surfaces the terminal immediately (→ exit 4). A fatal
286 /// auth failure (401/403) is NEVER backed off — it is a misconfiguration, not
287 /// a transient outage (§6).
288 pub fn complete(&self, req: &Request) -> Result<Response, IntelError> {
289 // --- RFC 0018 §4.3 metric call site: one model call (no-op without metrics).
290 crate::obs::metrics::record_intel_call();
291
292 let mut attempt: u32 = 0;
293 loop {
294 let sweep = {
295 let mut list = self.list.borrow_mut();
296 failover::complete_resilient(&mut list, req, self.timeout, self.trace_id.as_deref())
297 };
298
299 // `set_intel_up` reflects the active endpoint's reachability (in rotation).
300 {
301 let list = self.list.borrow();
302 let all_down = list.all_down();
303 let active_up = list.ep(list.active()).health.is_up();
304 crate::obs::metrics::set_intel_up(active_up && !all_down);
305 // RFC 0018 §6 upward report: fire ONLY on an all-down transition so
306 // the supervisor latches the child's reachability (edge-triggered —
307 // the steady state pays just this bool compare). transport+index
308 // only; no URL/secret. The data path above is untouched.
309 if let Some(report) = &self.health_reporter
310 && self.last_all_down.replace(all_down) != all_down
311 {
312 let active = (!all_down).then(|| list.active_identity());
313 report(IntelHealthReport { all_down, active });
314 }
315 }
316
317 match sweep.outcome {
318 Ok(resp) => return Ok(resp),
319 Err(e) => {
320 crate::obs::metrics::record_intel_error(error_reason(&e));
321 // All-down + a daemon backoff policy + an auth-free cause →
322 // back off and re-arm (§6). Anything else (a fatal class, no
323 // policy, or an exhausted budget) surfaces now.
324 let backoff = match (&self.alldown, &e) {
325 (Some(p), IntelError::AllEndpointsDown(cause))
326 if !cause.as_deref().is_some_and(failover::is_auth)
327 && attempt < p.max_retries =>
328 {
329 *p
330 }
331 _ => return Err(e),
332 };
333 let delay = backoff_delay(&backoff, attempt);
334 attempt += 1;
335 std::thread::sleep(delay);
336 // Loop: the next sweep promotes any elapsed-cooldown breaker to
337 // half-open and resumes the instant an endpoint recovers.
338 }
339 }
340 }
341 }
342
343 /// Borrow the endpoint list for the read-only `agentd://intelligence`
344 /// resource body (§4.4). The caller serializes transport/index/health only —
345 /// NEVER the URL or any credential (RFC 0012 §3.7).
346 pub fn with_list<R>(&self, f: impl FnOnce(&EndpointList) -> R) -> R {
347 f(&self.list.borrow())
348 }
349}
350
351/// The jittered backoff delay for all-down retry `attempt` (RFC 0018 §6): the
352/// exponential `base × 2^attempt` capped at `max`, then ±25% jitter from a cheap
353/// clock-seeded PRNG (no `rand` dependency — the minimalism moat).
354fn backoff_delay(policy: &AllDownPolicy, attempt: u32) -> Duration {
355 let shift = attempt.min(20);
356 let scaled = policy.base.saturating_mul(1u32 << shift).min(policy.max);
357 let ms = scaled.as_millis() as u64;
358 // ±25% jitter, drawn from a clock-seeded splitmix64 step (no `rand` dep).
359 let seed = std::time::SystemTime::now()
360 .duration_since(std::time::UNIX_EPOCH)
361 .map(|d| d.as_nanos() as u64)
362 .unwrap_or(0)
363 ^ (attempt as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
364 let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
365 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
366 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
367 z ^= z >> 31;
368 // Map z → [-25%, +25%] of ms: window width is ms/2 (rounded up).
369 let lo = ms.saturating_sub(ms / 4);
370 let window = (ms / 2) + 1;
371 Duration::from_millis(lo + z % window)
372}
373
374/// Map an [`IntelError`] to the frozen `agentd_intel_errors_total{reason}` label
375/// domain (RFC 0016 §4.3: `unreachable`|`auth`|`timeout`|`5xx`|`other`).
376fn error_reason(e: &IntelError) -> &'static str {
377 match e {
378 IntelError::Transport(io) => match io.kind() {
379 std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timeout",
380 _ => "unreachable",
381 },
382 IntelError::Http(401 | 403, _) => "auth",
383 IntelError::Http(c, _) if (500..600).contains(c) => "5xx",
384 IntelError::Http(_, _) => "other",
385 IntelError::Parse(_) | IntelError::Unsupported(_) => "other",
386 // All-down classifies by its underlying cause when present.
387 IntelError::AllEndpointsDown(Some(cause)) => error_reason(cause),
388 IntelError::AllEndpointsDown(None) => "unreachable",
389 }
390}
391
392/// Parse one intelligence URI element into (transport, http-path, host-header).
393/// Shared by [`super::endpoints`] (the list parser). HTTPS-only (target-vision
394/// pivot): `https://host[:port][/path]` is the transport; plaintext `http://`
395/// is admitted ONLY for a loopback host (the dev/test carve-out — the built-in
396/// mock LLM). Everything else — `unix:`, `vsock:`, non-loopback `http://` — is
397/// rejected here, the single chokepoint every construction path (CLI config,
398/// spawn payload, hot reload) flows through.
399pub(super) fn resolve(
400 uri: &str,
401 provider: Provider,
402) -> Result<(Transport, String, String), IntelError> {
403 let url = Url::parse(uri).map_err(|_| {
404 IntelError::Unsupported(format!(
405 "intelligence endpoint must be https://host[:port][/path] (got: {uri})"
406 ))
407 })?;
408 let tls = url.is_tls();
409 if !tls && !crate::net::http::is_loopback_host(&url.host) {
410 return Err(IntelError::Unsupported(format!(
411 "plaintext http:// intelligence is allowed for loopback only (dev); use https:// (got: {uri})"
412 )));
413 }
414 let http_path = if url.path == "/" {
415 provider.default_path().to_string()
416 } else {
417 url.path.clone()
418 };
419 let host_header = url.host_header();
420 Ok((
421 Transport::Tcp {
422 host: url.host,
423 port: url.port,
424 tls,
425 },
426 http_path,
427 host_header,
428 ))
429}
430
431impl Transport {
432 pub(super) fn connect(&self, timeout: Duration) -> Result<Box<dyn Stream>, IntelError> {
433 use crate::net::http;
434 match self {
435 Transport::Tcp {
436 host,
437 port,
438 tls: false,
439 } => Ok(Box::new(http::connect_tcp(host, *port, timeout)?)),
440 Transport::Tcp {
441 host,
442 port,
443 tls: true,
444 } => connect_tls(host, *port, timeout),
445 }
446 }
447}
448
449#[cfg(feature = "tls")]
450fn connect_tls(host: &str, port: u16, timeout: Duration) -> Result<Box<dyn Stream>, IntelError> {
451 let tcp = crate::net::http::connect_tcp(host, port, timeout)?;
452 Ok(Box::new(
453 // Server-auth TLS today; an optional client identity (mTLS) is threaded
454 // through in the auth chunk (Phase E).
455 crate::net::tls::connect(tcp, host, None).map_err(IntelError::Transport)?,
456 ))
457}
458
459#[cfg(not(feature = "tls"))]
460fn connect_tls(_host: &str, _port: u16, _timeout: Duration) -> Result<Box<dyn Stream>, IntelError> {
461 Err(IntelError::Unsupported(
462 "https:// intelligence requires building with --features tls".into(),
463 ))
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn resolve_rejects_non_https_transports() {
472 // HTTPS-only (pivot Phase 1): the old unix:/vsock: transports are gone —
473 // rejected at the single resolve() chokepoint, not feature-gated away.
474 for uri in ["unix:/run/intel.sock", "vsock:2:8080", "not-a-url"] {
475 let err = resolve(uri, Provider::OpenAiCompatible).unwrap_err();
476 assert!(
477 matches!(err, IntelError::Unsupported(_)),
478 "{uri} must be rejected, got: {err:?}"
479 );
480 }
481 }
482
483 #[test]
484 fn resolve_allows_plaintext_http_for_loopback_only() {
485 // The dev/test carve-out: the built-in mock LLM binds 127.0.0.1.
486 for uri in [
487 "http://127.0.0.1:8080",
488 "http://localhost:8080",
489 "http://[::1]:8080",
490 ] {
491 let (t, _p, _h) = resolve(uri, Provider::OpenAiCompatible).unwrap();
492 assert!(matches!(t, Transport::Tcp { tls: false, .. }), "{uri}");
493 }
494 let err = resolve("http://intel.example:8080", Provider::OpenAiCompatible).unwrap_err();
495 assert!(
496 matches!(err, IntelError::Unsupported(m) if m.contains("loopback")),
497 "non-loopback plaintext must be rejected"
498 );
499 }
500
501 #[test]
502 fn resolve_https_full_url() {
503 let (t, path, host) = resolve(
504 "https://api.openai.com/v1/chat/completions",
505 Provider::OpenAiCompatible,
506 )
507 .unwrap();
508 assert!(matches!(
509 t,
510 Transport::Tcp {
511 tls: true,
512 port: 443,
513 ..
514 }
515 ));
516 assert_eq!(path, "/v1/chat/completions");
517 assert_eq!(host, "api.openai.com");
518 }
519
520 #[test]
521 fn resolve_https_host_only_uses_default_path() {
522 let (_t, path, _host) =
523 resolve("https://gateway.local", Provider::OpenAiCompatible).unwrap();
524 assert_eq!(path, "/v1/chat/completions");
525 }
526
527 #[test]
528 fn single_endpoint_client_builds() {
529 let c = IntelClient::from_parts("https://intel.example", None).unwrap();
530 assert_eq!(c.endpoint_count(), 1);
531 }
532
533 #[test]
534 fn comma_list_client_builds_with_all_endpoints() {
535 let c = IntelClient::from_parts(
536 "https://a.example,https://b.example,https://c.example",
537 None,
538 )
539 .unwrap();
540 assert_eq!(c.endpoint_count(), 3);
541 }
542
543 #[test]
544 fn all_endpoints_down_maps_to_unreachable_reason() {
545 // exercises the §4.3 reason mapping for the §6 terminal.
546 let cause = Box::new(IntelError::Http(503, "x".into()));
547 assert_eq!(
548 error_reason(&IntelError::AllEndpointsDown(Some(cause))),
549 "5xx"
550 );
551 assert_eq!(
552 error_reason(&IntelError::AllEndpointsDown(None)),
553 "unreachable"
554 );
555 assert_eq!(error_reason(&IntelError::Http(401, "x".into())), "auth");
556 }
557
558 #[test]
559 fn trace_header_propagates_to_endpoint_dialect() {
560 // Construction does not connect; we only assert the trace id is held and
561 // would be applied per endpoint (the per-endpoint dial appends it).
562 let mut c = IntelClient::from_parts("https://intel.example", None).unwrap();
563 assert!(c.trace_id.is_none());
564 c.set_trace_id(Some("1234567890abcdef1234567890abcdef".into()));
565 assert!(c.trace_id.is_some());
566 }
567}