Skip to main content

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}