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}