contextgraph_types/error_code.rs
1//! Structured error codes (`SPEC.md` §Errors, issue #9).
2//!
3//! Before this module the wire error was a bare string. A host receiving one
4//! could log it and nothing else: it could not distinguish "your query was
5//! malformed" (do not retry) from "I am overloaded" (retry with backoff) from
6//! "I do not serve that frame kind" (stop asking) from "internal fault" (fail
7//! over). Every SDK and host would have invented its own message-string
8//! sniffing — exactly the convention-over-contract the protocol exists to
9//! eliminate.
10//!
11//! [`ErrorCode`] is a small, open vocabulary carried **alongside** the
12//! free-form message, never replacing it: the code is for the machine, the
13//! message is for the human reading the log.
14//!
15//! # Forward compatibility
16//!
17//! The vocabulary is open. An unrecognised code round-trips losslessly as
18//! [`ErrorCode::Unknown`] and **MUST** be reacted to as though it were
19//! [`ErrorCode::Internal`] — the conservative choice, since a host that guessed
20//! optimistically about an error it does not understand would retry things it
21//! should not. This is what lets the vocabulary grow in a 1.x minor without
22//! breaking deployed hosts.
23
24use serde::{Deserialize, Deserializer, Serialize, Serializer};
25
26/// What a host should do about a provider error. Advisory guidance attached to
27/// each [`ErrorCode`], so the reaction lives with the vocabulary rather than
28/// being re-derived by every host.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum HostReaction {
31 /// The request itself was wrong. Retrying it unchanged will fail again.
32 DoNotRetry,
33 /// The provider does not serve exactly what was asked for. Adjust the
34 /// request — narrow the query's `kinds`, or downgrade
35 /// `representation_preferences` to `full` — or stop querying this provider
36 /// for it.
37 NarrowOrSkip,
38 /// No useful frame fits the stated budget. Raise `max_tokens` or skip.
39 RaiseBudgetOrSkip,
40 /// Transient. Retry with backoff.
41 RetryWithBackoff,
42 /// The provider is tearing down. Re-spawn it or drop it from the fan-out.
43 Respawn,
44 /// The provider is permanently unusable — e.g. a handshake version family
45 /// that shares no major with the host (`SPEC.md` §H3). Drop it from the
46 /// fan-out; retrying cannot help, and it is not a health blip to count and
47 /// keep. Distinct from [`DoNotRetry`](Self::DoNotRetry) (there the *request*
48 /// was wrong) and [`Respawn`](Self::Respawn) (there a retry could succeed).
49 DropProvider,
50 /// A provider fault. Report it and count it against the provider's health.
51 ReportAndCount,
52}
53
54/// A machine-readable provider error code.
55///
56/// Serializes as a snake_case string, so the wire stays human-diffable:
57/// `{"type":"error","code":"unsupported_kind","message":"..."}`.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum ErrorCode {
60 /// Malformed or unintelligible query.
61 BadRequest,
62 /// The requested frame kinds are not served by this provider.
63 UnsupportedKind,
64 /// The host asked for a representation the provider did not advertise in
65 /// `capabilities.representations` (`SPEC.md` §P5). The host should
66 /// re-request `full` or skip the provider.
67 UnsupportedRepresentation,
68 /// The handshake version families do not share a major, so the peers cannot
69 /// interoperate (`SPEC.md` §H3). Permanent — a host **MUST NOT** read it as
70 /// retryable — so it maps to [`HostReaction::DropProvider`], never a retry.
71 IncompatibleVersion,
72 /// The budget is too small for any meaningful frame.
73 BudgetUnsatisfiable,
74 /// Transient overload, or a backing store is down.
75 Unavailable,
76 /// The provider is shutting down.
77 ShuttingDown,
78 /// A provider-side fault.
79 Internal,
80 /// A code this implementation does not recognise. Preserved verbatim so it
81 /// survives a round-trip, and treated as [`Internal`](Self::Internal) for
82 /// the purpose of [`reaction`](Self::reaction).
83 Unknown(String),
84}
85
86impl ErrorCode {
87 /// The wire spelling of this code.
88 pub fn as_str(&self) -> &str {
89 match self {
90 Self::BadRequest => "bad_request",
91 Self::UnsupportedKind => "unsupported_kind",
92 Self::UnsupportedRepresentation => "unsupported_representation",
93 Self::IncompatibleVersion => "incompatible_version",
94 Self::BudgetUnsatisfiable => "budget_unsatisfiable",
95 Self::Unavailable => "unavailable",
96 Self::ShuttingDown => "shutting_down",
97 Self::Internal => "internal",
98 Self::Unknown(raw) => raw,
99 }
100 }
101
102 /// The advised host reaction. An unknown code reacts as `Internal` — see
103 /// the module docs on why the conservative choice is the correct one.
104 pub fn reaction(&self) -> HostReaction {
105 match self {
106 Self::BadRequest => HostReaction::DoNotRetry,
107 Self::UnsupportedKind => HostReaction::NarrowOrSkip,
108 Self::UnsupportedRepresentation => HostReaction::NarrowOrSkip,
109 Self::IncompatibleVersion => HostReaction::DropProvider,
110 Self::BudgetUnsatisfiable => HostReaction::RaiseBudgetOrSkip,
111 Self::Unavailable => HostReaction::RetryWithBackoff,
112 Self::ShuttingDown => HostReaction::Respawn,
113 Self::Internal | Self::Unknown(_) => HostReaction::ReportAndCount,
114 }
115 }
116
117 /// Whether retrying the identical request could plausibly succeed.
118 pub fn is_retryable(&self) -> bool {
119 matches!(
120 self.reaction(),
121 HostReaction::RetryWithBackoff | HostReaction::Respawn
122 )
123 }
124
125 /// Whether this code was recognised by this implementation.
126 pub fn is_recognized(&self) -> bool {
127 !matches!(self, Self::Unknown(_))
128 }
129}
130
131impl From<&str> for ErrorCode {
132 fn from(raw: &str) -> Self {
133 match raw {
134 "bad_request" => Self::BadRequest,
135 "unsupported_kind" => Self::UnsupportedKind,
136 "unsupported_representation" => Self::UnsupportedRepresentation,
137 "incompatible_version" => Self::IncompatibleVersion,
138 "budget_unsatisfiable" => Self::BudgetUnsatisfiable,
139 "unavailable" => Self::Unavailable,
140 "shutting_down" => Self::ShuttingDown,
141 "internal" => Self::Internal,
142 other => Self::Unknown(other.to_string()),
143 }
144 }
145}
146
147impl std::fmt::Display for ErrorCode {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.write_str(self.as_str())
150 }
151}
152
153impl Serialize for ErrorCode {
154 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
155 s.serialize_str(self.as_str())
156 }
157}
158
159impl<'de> Deserialize<'de> for ErrorCode {
160 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
161 // A plain `#[derive(Deserialize)]` on a unit-variant enum would *reject*
162 // an unrecognised code, which would make adding a code in a 1.x minor a
163 // breaking change for every deployed host. Going through the string
164 // keeps the vocabulary open.
165 let raw = String::deserialize(d)?;
166 Ok(ErrorCode::from(raw.as_str()))
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn every_known_code_roundtrips_through_its_wire_spelling() {
176 let codes = [
177 ErrorCode::BadRequest,
178 ErrorCode::UnsupportedKind,
179 ErrorCode::UnsupportedRepresentation,
180 ErrorCode::IncompatibleVersion,
181 ErrorCode::BudgetUnsatisfiable,
182 ErrorCode::Unavailable,
183 ErrorCode::ShuttingDown,
184 ErrorCode::Internal,
185 ];
186 for code in codes {
187 let json = serde_json::to_string(&code).unwrap();
188 let back: ErrorCode = serde_json::from_str(&json).unwrap();
189 assert_eq!(back, code, "{code} did not survive a round-trip");
190 assert!(code.is_recognized());
191 }
192 }
193
194 #[test]
195 fn codes_serialize_as_bare_snake_case_strings() {
196 assert_eq!(
197 serde_json::to_string(&ErrorCode::UnsupportedKind).unwrap(),
198 "\"unsupported_kind\""
199 );
200 }
201
202 #[test]
203 fn an_unknown_code_survives_a_roundtrip_verbatim() {
204 // Forward compatibility: a host built today must be able to receive,
205 // log, and re-emit a code added to the spec tomorrow.
206 let code: ErrorCode = serde_json::from_str("\"quota_exceeded\"").unwrap();
207 assert_eq!(code, ErrorCode::Unknown("quota_exceeded".into()));
208 assert_eq!(serde_json::to_string(&code).unwrap(), "\"quota_exceeded\"");
209 assert!(!code.is_recognized());
210 }
211
212 #[test]
213 fn an_unknown_code_reacts_as_internal_never_as_retryable() {
214 // The conservative default: a host that optimistically retried an
215 // error it did not understand could hammer a provider that told it to
216 // stop.
217 let code = ErrorCode::Unknown("something_new".into());
218 assert_eq!(code.reaction(), HostReaction::ReportAndCount);
219 assert!(!code.is_retryable());
220 }
221
222 #[test]
223 fn reactions_separate_retryable_faults_from_permanent_ones() {
224 assert!(ErrorCode::Unavailable.is_retryable());
225 assert!(ErrorCode::ShuttingDown.is_retryable());
226
227 assert!(!ErrorCode::BadRequest.is_retryable());
228 assert!(!ErrorCode::UnsupportedKind.is_retryable());
229 assert!(!ErrorCode::UnsupportedRepresentation.is_retryable());
230 assert!(!ErrorCode::BudgetUnsatisfiable.is_retryable());
231 assert!(!ErrorCode::Internal.is_retryable());
232
233 // §H3: a version-family mismatch is permanent — the host drops the
234 // provider rather than retrying it.
235 assert!(!ErrorCode::IncompatibleVersion.is_retryable());
236 assert_eq!(
237 ErrorCode::IncompatibleVersion.reaction(),
238 HostReaction::DropProvider
239 );
240 }
241}