Skip to main content

khive_wire_protocol/
error.rs

1//! The wire error taxonomy from ADR-137's "Wire error taxonomy" section.
2//!
3//! Wire-level errors are distinct from verb-level DSL errors (ADR-016): a
4//! wire error is returned before or instead of DSL dispatch, while a
5//! DSL-level per-operation error is a separate failure mode carried inside a
6//! successful [`crate::frame::Frame::Response`] frame's payload.
7
8use serde::{Deserialize, Serialize};
9
10/// Where a wire error terminates: the whole connection, or just the
11/// operation id it echoes.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum TerminalScope {
14    /// The error is followed by connection close. It carries no operation
15    /// id.
16    Connection,
17    /// The error terminates only the operation id it echoes (a request,
18    /// subscribe, or unsubscribe id); the connection stays usable.
19    Request,
20}
21
22/// The closed set of wire-level error codes for protocol version 1
23/// (ADR-137, "Wire error taxonomy").
24///
25/// The set is closed within a protocol version: adding a code requires a
26/// protocol version bump ([`crate::version`]). Serialized names are
27/// `snake_case` and match the ADR exactly.
28///
29/// A decoder that encounters a code string it does not recognize falls back
30/// to [`WireErrorCode::Internal`] per the ADR: *"a client that receives a
31/// code it does not recognize must treat it as `internal` — request-terminal,
32/// retriable only under the caller's own policy — rather than inventing
33/// semantics for it."* This is implemented with `#[serde(other)]`, so an
34/// older client talking to a server that has gained a new code under a later
35/// protocol version degrades to this documented behavior instead of a decode
36/// failure.
37/// The wire (`snake_case`) names of every code in the closed set for
38/// protocol version 1, in ADR-137's "Wire error taxonomy" table order.
39///
40/// The codec's decode-time error-scope check applies only to codes in this
41/// set: their terminal scopes are fixed by the table. A code outside the
42/// set fell back to [`WireErrorCode::Internal`] via `#[serde(other)]`; its
43/// true scope is unknown to this protocol version, and ADR-137 directs the
44/// client to treat it as `internal` rather than reject the frame. Code
45/// names are case-sensitive: a case-variant of a known code (`"Cancelled"`)
46/// is simply an unknown code string and takes the same documented fallback
47/// — the exemption is one rule, not a bypass of the closed set.
48pub const WIRE_ERROR_CODES: &[&str] = &[
49    "unsupported_version",
50    "identity_rejected",
51    "malformed_frame",
52    "frame_too_large",
53    "subscriber_overflow",
54    "subscription_revoked",
55    "context_rejected",
56    "peer_class_denied",
57    "subscription_denied",
58    "already_subscribed",
59    "cursor_expired",
60    "in_flight_limit_exceeded",
61    "deadline_exceeded",
62    "cancelled",
63    "shutting_down",
64    "internal",
65];
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum WireErrorCode {
70    /// Handshake named no mutually supported protocol version. Connection.
71    UnsupportedVersion,
72    /// Whois lookup failed, no or invalid node mapping, or native-frame
73    /// ingress attempted by a class without native access. Connection.
74    IdentityRejected,
75    /// A frame cannot be decoded or violates the frame grammar. Connection.
76    MalformedFrame,
77    /// A frame exceeds the configured maximum size. Connection.
78    FrameTooLarge,
79    /// The connection's bounded outbound event queue reached its limit.
80    /// Connection.
81    SubscriberOverflow,
82    /// A live mapping or peer-class change short of mapping deletion removed
83    /// authorization for a topic with an active subscription on this
84    /// connection. Connection.
85    SubscriptionRevoked,
86    /// A frame-level attempt to supply `namespace`, `actor_id`, or
87    /// `visible_namespaces` on a mapped transport. Request.
88    ContextRejected,
89    /// A parsed operation names a verb outside the mapped class allowlist.
90    /// Request.
91    PeerClassDenied,
92    /// A `subscribe` names a topic outside the mapped class's allowed-topic
93    /// set. Request.
94    SubscriptionDenied,
95    /// A `subscribe` names a topic that already has an active subscription
96    /// on this connection. Request.
97    AlreadySubscribed,
98    /// A `subscribe` resume cursor is older than the topic's retention
99    /// window. Request.
100    CursorExpired,
101    /// A `request` arrives while the per-connection in-flight limit is
102    /// reached. Request.
103    InFlightLimitExceeded,
104    /// The server abandoned the request at its deadline. Request.
105    DeadlineExceeded,
106    /// The request was terminated by a `cancel` frame before its normal
107    /// completion. Request.
108    Cancelled,
109    /// The server is draining and refuses new work. Request.
110    ShuttingDown,
111    /// An unclassified server-side transport failure. Request. Also the
112    /// fallback for an error code this client does not recognize.
113    #[serde(other)]
114    Internal,
115}
116
117impl WireErrorCode {
118    /// The terminal scope defined for this code in ADR-137's error table.
119    pub const fn terminal_scope(self) -> TerminalScope {
120        use WireErrorCode::*;
121        match self {
122            UnsupportedVersion | IdentityRejected | MalformedFrame | FrameTooLarge
123            | SubscriberOverflow | SubscriptionRevoked => TerminalScope::Connection,
124            ContextRejected
125            | PeerClassDenied
126            | SubscriptionDenied
127            | AlreadySubscribed
128            | CursorExpired
129            | InFlightLimitExceeded
130            | DeadlineExceeded
131            | Cancelled
132            | ShuttingDown
133            | Internal => TerminalScope::Request,
134        }
135    }
136
137    /// The wire (`snake_case`) name of this code, as it appears on the wire
138    /// and in ADR-137's table.
139    pub const fn as_str(self) -> &'static str {
140        use WireErrorCode::*;
141        match self {
142            UnsupportedVersion => "unsupported_version",
143            IdentityRejected => "identity_rejected",
144            MalformedFrame => "malformed_frame",
145            FrameTooLarge => "frame_too_large",
146            SubscriberOverflow => "subscriber_overflow",
147            SubscriptionRevoked => "subscription_revoked",
148            ContextRejected => "context_rejected",
149            PeerClassDenied => "peer_class_denied",
150            SubscriptionDenied => "subscription_denied",
151            AlreadySubscribed => "already_subscribed",
152            CursorExpired => "cursor_expired",
153            InFlightLimitExceeded => "in_flight_limit_exceeded",
154            DeadlineExceeded => "deadline_exceeded",
155            Cancelled => "cancelled",
156            ShuttingDown => "shutting_down",
157            Internal => "internal",
158        }
159    }
160}
161
162impl std::fmt::Display for WireErrorCode {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.write_str(self.as_str())
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn unrecognized_code_falls_back_to_internal() {
174        // ADR-137: a code this client does not recognize must decode as
175        // `internal` — request-terminal — rather than failing to decode.
176        let code: WireErrorCode = serde_json::from_str(r#""bogus""#).unwrap();
177        assert_eq!(code, WireErrorCode::Internal);
178        assert_eq!(code.terminal_scope(), TerminalScope::Request);
179    }
180
181    #[test]
182    fn unrecognized_code_inside_an_error_frame_falls_back_to_internal() {
183        let payload = br#"{"kind":"error","code":"bogus","message":"future code"}"#;
184        let frame = crate::codec::decode_payload(payload).unwrap();
185        match frame {
186            crate::frame::Frame::Error {
187                id,
188                code,
189                unrecognized_code,
190                ..
191            } => {
192                assert_eq!(code, WireErrorCode::Internal);
193                assert!(id.is_none());
194                // The raw string survives the fallback; it is not
195                // silently discarded.
196                assert_eq!(unrecognized_code.as_deref(), Some("bogus"));
197            }
198            other => panic!("expected an error frame, got {other:?}"),
199        }
200    }
201
202    #[test]
203    fn wire_error_codes_matches_the_code_enum() {
204        // `WIRE_ERROR_CODES` gates the codec's decode-time error-scope
205        // check: a code missing from the set silently takes the
206        // unknown-code exemption and its scope pairing is never enforced.
207        // The set must therefore cover every `WireErrorCode` variant
208        // exactly.
209        //
210        // Set side, fully mechanical: every entry must round-trip through
211        // serde to a variant whose `as_str` equals the entry. A typo'd or
212        // stale entry deserializes to `Internal` via `#[serde(other)]` and
213        // fails the `as_str` comparison.
214        for entry in WIRE_ERROR_CODES {
215            let json = format!("\"{entry}\"");
216            let parsed: WireErrorCode = serde_json::from_str(&json).unwrap();
217            assert_eq!(
218                parsed.as_str(),
219                *entry,
220                "WIRE_ERROR_CODES entry {entry:?} names no variant \
221                 (deserialized to {parsed:?})"
222            );
223        }
224        let mut deduped: Vec<&str> = WIRE_ERROR_CODES.to_vec();
225        deduped.sort_unstable();
226        deduped.dedup();
227        assert_eq!(
228            deduped.len(),
229            WIRE_ERROR_CODES.len(),
230            "WIRE_ERROR_CODES contains duplicates"
231        );
232
233        // Variant side: the list below mirrors the enum; the wildcard-free
234        // match forces whoever adds a variant to visit this site (the
235        // compiler cannot force the array itself). Each variant must be in
236        // the set, and equal counts close the loop.
237        use WireErrorCode::*;
238        let variants = [
239            UnsupportedVersion,
240            IdentityRejected,
241            MalformedFrame,
242            FrameTooLarge,
243            SubscriberOverflow,
244            SubscriptionRevoked,
245            ContextRejected,
246            PeerClassDenied,
247            SubscriptionDenied,
248            AlreadySubscribed,
249            CursorExpired,
250            InFlightLimitExceeded,
251            DeadlineExceeded,
252            Cancelled,
253            ShuttingDown,
254            Internal,
255        ];
256        for code in variants {
257            match code {
258                UnsupportedVersion
259                | IdentityRejected
260                | MalformedFrame
261                | FrameTooLarge
262                | SubscriberOverflow
263                | SubscriptionRevoked
264                | ContextRejected
265                | PeerClassDenied
266                | SubscriptionDenied
267                | AlreadySubscribed
268                | CursorExpired
269                | InFlightLimitExceeded
270                | DeadlineExceeded
271                | Cancelled
272                | ShuttingDown
273                | Internal => {}
274            }
275            assert!(
276                WIRE_ERROR_CODES.contains(&code.as_str()),
277                "WIRE_ERROR_CODES is missing {code:?} ({}); its scope pairing \
278                 would silently go unenforced at decode",
279                code.as_str()
280            );
281        }
282        assert_eq!(
283            WIRE_ERROR_CODES.len(),
284            variants.len(),
285            "WIRE_ERROR_CODES and the variant list disagree on count"
286        );
287    }
288}