Skip to main content

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}