contextgraph_host/error.rs
1//! `HostError` — the one typed error the host runtime raises
2//! (`SPEC.md` §10 "fail loud"). Everything a fan-out or a single
3//! provider exchange can go wrong with is a named variant here; nothing in
4//! the hot path panics. `contextgraph-host` owns its own error type rather than
5//! borrowing `stella`'s so the crate stays industry-facing and dependency-
6//! light (`SPEC.md` §1 — depends only on `contextgraph-types` + transport
7//! crates).
8
9use contextgraph_types::{DataFlow, EgressScope, ErrorCode};
10
11/// Anything the host runtime can surface while talking to a provider.
12#[derive(Debug, thiserror::Error)]
13pub enum HostError {
14 /// The provider speaks an incompatible protocol family
15 /// (`SPEC.md`). Reported the instant the handshake ack
16 /// arrives — never a hang (task deliverable 1).
17 #[error(
18 "protocol version mismatch: host speaks {host}, provider {provider} speaks {provider_version}"
19 )]
20 VersionMismatch {
21 host: String,
22 provider: String,
23 provider_version: String,
24 },
25
26 /// A line/body could not be encoded to or decoded from the wire envelope
27 /// (`SPEC.md` §2). A malformed provider message is a
28 /// clean error, never a host crash (task deliverable 5).
29 #[error("wire encode/decode error: {0}")]
30 Wire(String),
31
32 /// The underlying transport (stdio pipe or HTTP) failed.
33 #[error("transport error talking to provider {id}: {message}")]
34 Transport { id: String, message: String },
35
36 /// The host refused to open a plaintext (`http://`) transport to a
37 /// non-loopback provider (`SPEC.md` §4.2, **C7**): the query payload — and
38 /// any bearer credential — would cross the network in cleartext. Raised
39 /// **before** any bytes are sent, so nothing left the host. The message
40 /// names only the id and host — never a credential (C8).
41 #[error(
42 "refusing an insecure (plaintext http) transport to non-loopback provider {id} at host `{host}`: TLS is required for any non-loopback provider (C7)"
43 )]
44 InsecureTransport { id: String, host: String },
45
46 /// The provider rejected the host's bearer credential (`HTTP 401`). Distinct
47 /// from a bare [`Transport`](Self::Transport) failure so a host can react to
48 /// an auth rejection specifically. The message names only the id and the
49 /// status — never the credential itself (`SPEC.md` §4.2, **C8**).
50 #[error("provider {id} rejected the host credential (HTTP 401 Unauthorized)")]
51 Unauthorized { id: String },
52
53 /// The provider's child process closed its stream mid-exchange — it
54 /// crashed. Isolated to this provider; never poisons a `query_all`
55 /// (task deliverable 5).
56 #[error("provider {id} crashed or closed its stream mid-exchange")]
57 ProviderCrashed { id: String },
58
59 /// The provider took longer than the host's per-provider budget.
60 #[error("provider {id} timed out after {timeout_ms}ms")]
61 Timeout { id: String, timeout_ms: u64 },
62
63 /// The provider reported an error over the wire (an `error` envelope).
64 ///
65 /// `code` carries the structured [`ErrorCode`] the provider sent (#9) so it
66 /// survives the transport boundary instead of collapsing to a bare message;
67 /// a host can then key its reaction ([`ErrorCode::reaction`]) off the code
68 /// rather than sniffing the free-form string. `None` when the provider
69 /// declared no code — read it as [`ErrorCode::Internal`] per SPEC.md.
70 #[error(
71 "provider {id} reported an error{}: {message}",
72 .code.as_ref().map(|c| format!(" ({c})")).unwrap_or_default()
73 )]
74 Provider {
75 id: String,
76 code: Option<ErrorCode>,
77 message: String,
78 },
79
80 /// The provider declares `egress` and has no recorded consent, so the
81 /// host refuses to transmit a query to it (`SPEC.md`
82 /// SPEC.md §4 — a host MUST NOT auto-enable egress providers). The query
83 /// payload never left the host.
84 #[error(
85 "provider {id} declares egress and requires one-time consent naming what leaves before it can be queried"
86 )]
87 ConsentRequired { id: String, data_flow: DataFlow },
88
89 /// The provider declares one or more **off-machine egress scopes** with no
90 /// recorded consent receipt, so the host refuses to transmit a query to it
91 /// (`docs/context-reuse.md` §3 — requirement C6). `scopes` names exactly
92 /// the scopes that would leave unconsented. The query payload never left
93 /// the host.
94 ///
95 // NOTE: the stable `code` string for the typed-error-code work (#9) is not
96 // yet assigned; it slots in alongside `ConsentRequired` when #9 lands.
97 #[error(
98 "provider {id} declares egress scope(s) {scopes:?} with no recorded consent receipt; the query was not transmitted"
99 )]
100 ConsentScopeRequired {
101 id: String,
102 scopes: Vec<EgressScope>,
103 },
104
105 /// A message of the wrong kind arrived where the protocol expected a
106 /// specific envelope (e.g. a `frames` reply to a `query`).
107 #[error("expected a `{expected}` envelope from provider {id}, got `{got}`")]
108 UnexpectedEnvelope {
109 id: String,
110 expected: String,
111 got: String,
112 },
113
114 /// A provider that declared `correlation` answered without echoing the
115 /// request's `id`, or echoed the wrong one (`SPEC.md` §H4).
116 ///
117 /// Fatal to the exchange rather than a warning: once replies cannot be
118 /// matched to requests, a pipelining host could hand one caller's frames to
119 /// another, and silently mixing evidence between tasks is worse than
120 /// failing the query.
121 #[error("provider {id} broke request correlation: expected id `{expected}`, got `{got}`")]
122 CorrelationMismatch {
123 id: String,
124 expected: String,
125 got: String,
126 },
127
128 /// No provider is registered under the given id.
129 #[error("no provider registered with id `{0}`")]
130 UnknownProvider(String),
131
132 /// Spawning the provider child process failed.
133 #[error("failed to spawn provider process: {0}")]
134 Spawn(String),
135}