kanade_shared/ipc/error.rs
1//! KLP error model (SPEC §2.12.9).
2//!
3//! KLP carries errors inside the JSON-RPC 2.0 `error` object, with
4//! the canonical envelope:
5//!
6//! ```jsonc
7//! {"jsonrpc":"2.0","id":"u5","error":{
8//! "code": -32000,
9//! "message": "Job not user-invokable",
10//! "data": {"kind":"Unauthorized","detail":"manifest 'reboot' has user_invokable=false"}
11//! }}
12//! ```
13//!
14//! The `code` field follows JSON-RPC convention (-32700 / -32600
15//! /-32601 / -32602 / -32603 are reserved by the spec; -32000 ..
16//! -32099 are application-defined). `data.kind` is the canonical
17//! machine-readable label — agents and clients should switch on
18//! `kind`, not `code` (a future SPEC bump may reshuffle codes).
19
20use serde::{Deserialize, Serialize};
21
22/// All KLP-defined error kinds (SPEC §2.12.9 table).
23///
24/// Wire-encoded verbatim as the variant name (`"Unauthorized"`,
25/// `"RateLimit"`, `"StaleProtocol"`, …) to match the SPEC §2.12.9
26/// table 1:1 — this is the one place in the codebase that breaks
27/// from the otherwise-uniform `snake_case` convention, because the
28/// spec doc shows PascalCase wire and we keep that contract.
29///
30/// `#[non_exhaustive]` so SPEC §2.12.9 can grow new error kinds in
31/// a future revision without forcing a wire-protocol bump —
32/// downstream Rust consumers see a compile-time nudge to add a
33/// wildcard arm.
34#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
35#[non_exhaustive]
36pub enum ErrorKind {
37 /// `-32700` — request body wasn't valid JSON.
38 ParseError,
39 /// `-32600` — JSON-RPC envelope rejected (missing `jsonrpc`
40 /// field, non-string method, etc.) OR a non-handshake method
41 /// was invoked before `system.handshake` completed.
42 InvalidRequest,
43 /// `-32601` — method name not registered on this agent's
44 /// dispatcher.
45 MethodNotFound,
46 /// `-32602` — `params` failed schema validation for the named
47 /// method.
48 InvalidParams,
49 /// `-32603` — agent-side panic / unexpected failure. Always
50 /// returned with a redacted message — the original error lives
51 /// in the agent log.
52 InternalError,
53 /// `-32000` — authorization failure. The connection is authed
54 /// (we know the SID/UID) but the caller isn't allowed to do
55 /// this thing: invoking a job whose manifest has
56 /// `user_invokable=false`, killing a `run_id` belonging to
57 /// another connection, ack-ing a notification not addressed to
58 /// the caller's PC / group / `all` audience.
59 Unauthorized,
60 /// `-32001` — referenced `job_id` / `run_id` / `notification_id`
61 /// doesn't exist.
62 NotFound,
63 /// `-32002` — agent isn't connected to NATS right now, so
64 /// fan-out / publish-side operations can't be served. The
65 /// client SHOULD show a transient banner and retry on the
66 /// next state push.
67 AgentDisconnected,
68 /// `-32003` — connection exceeded the 60 req/min cap. The
69 /// client SHOULD back off (the agent doesn't tell it for how
70 /// long; 1 s is a safe minimum).
71 RateLimit,
72 /// `-32004` — handshake negotiated a protocol version that one
73 /// side no longer supports. Treat as fatal: the client must
74 /// upgrade (or the agent must be downgraded).
75 StaleProtocol,
76 /// `-32005` — message body exceeded the 1 MiB framing limit
77 /// (SPEC §2.12.2). `stdout_chunk` payloads must be split before
78 /// hitting this.
79 PayloadTooLarge,
80 /// #492: serde-level forward-compat catch-all — a newer peer's
81 /// new error kind decodes here instead of making the older side
82 /// fail to decode the whole RpcError. Maps to the JSON-RPC
83 /// generic server-error code.
84 #[serde(other)]
85 Unknown,
86}
87
88impl ErrorKind {
89 /// JSON-RPC `code` field for this kind. Pre-baked so the dispatch
90 /// path doesn't accidentally drift away from the table — the only
91 /// blessed mapping lives here.
92 pub fn code(self) -> i32 {
93 match self {
94 Self::ParseError => -32700,
95 Self::InvalidRequest => -32600,
96 Self::MethodNotFound => -32601,
97 Self::InvalidParams => -32602,
98 Self::InternalError => -32603,
99 Self::Unauthorized => -32000,
100 Self::NotFound => -32001,
101 Self::AgentDisconnected => -32002,
102 Self::RateLimit => -32003,
103 Self::StaleProtocol => -32004,
104 Self::PayloadTooLarge => -32005,
105 // JSON-RPC reserves -32099..-32000 for server errors;
106 // -32099 marks "kind unknown to this build".
107 Self::Unknown => -32099,
108 }
109 }
110
111 /// Default human-readable `message`. Agents may override per-call
112 /// when they have a more specific phrasing; tests and the spec
113 /// table use these.
114 pub fn default_message(self) -> &'static str {
115 match self {
116 Self::ParseError => "Parse error",
117 Self::InvalidRequest => "Invalid Request",
118 Self::MethodNotFound => "Method not found",
119 Self::InvalidParams => "Invalid params",
120 Self::InternalError => "Internal error",
121 Self::Unauthorized => "Unauthorized",
122 Self::NotFound => "Not found",
123 Self::AgentDisconnected => "Agent disconnected from broker",
124 Self::RateLimit => "Rate limit exceeded",
125 Self::StaleProtocol => "Stale protocol version",
126 Self::PayloadTooLarge => "Payload too large",
127 Self::Unknown => "Unknown error kind (newer peer)",
128 }
129 }
130}
131
132/// JSON-RPC 2.0 `error` object (SPEC §2.12.9). Always paired with a
133/// non-null `id` inside an [`super::envelope::RpcResponse`] — there
134/// is no notion of an error notification in KLP.
135#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
136pub struct RpcError {
137 pub code: i32,
138 pub message: String,
139 /// Structured detail. Wire-optional so the JSON-RPC reserved
140 /// codes (-32600 / -32601 / -32602 / -32603 / -32700) can be
141 /// returned with just `{code, message}` when the agent has no
142 /// extra context.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub data: Option<RpcErrorData>,
145}
146
147/// Application-side payload inside [`RpcError::data`].
148#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
149pub struct RpcErrorData {
150 pub kind: ErrorKind,
151 /// Free-form human-readable elaboration. Safe to surface in the
152 /// SPA toast; never carries secrets (agent code redacts before
153 /// constructing).
154 pub detail: String,
155}
156
157impl RpcError {
158 /// Build an error matching SPEC §2.12.9's canonical shape:
159 /// `code` derived from `kind`, `message` defaulted from `kind`,
160 /// `data` populated with `{kind, detail}`.
161 pub fn new(kind: ErrorKind, detail: impl Into<String>) -> Self {
162 Self {
163 code: kind.code(),
164 message: kind.default_message().to_string(),
165 data: Some(RpcErrorData {
166 kind,
167 detail: detail.into(),
168 }),
169 }
170 }
171
172 /// Bare error without `data`. Use only for the JSON-RPC reserved
173 /// codes where the spec allows omitting structured detail (parse
174 /// errors before the envelope can be decoded, etc.).
175 pub fn bare(kind: ErrorKind) -> Self {
176 Self {
177 code: kind.code(),
178 message: kind.default_message().to_string(),
179 data: None,
180 }
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn error_kind_codes_match_spec_table() {
190 // SPEC §2.12.9 — pinned so a refactor of the table can't
191 // silently drift codes apart.
192 assert_eq!(ErrorKind::ParseError.code(), -32700);
193 assert_eq!(ErrorKind::InvalidRequest.code(), -32600);
194 assert_eq!(ErrorKind::MethodNotFound.code(), -32601);
195 assert_eq!(ErrorKind::InvalidParams.code(), -32602);
196 assert_eq!(ErrorKind::InternalError.code(), -32603);
197 assert_eq!(ErrorKind::Unauthorized.code(), -32000);
198 assert_eq!(ErrorKind::NotFound.code(), -32001);
199 assert_eq!(ErrorKind::AgentDisconnected.code(), -32002);
200 assert_eq!(ErrorKind::RateLimit.code(), -32003);
201 assert_eq!(ErrorKind::StaleProtocol.code(), -32004);
202 assert_eq!(ErrorKind::PayloadTooLarge.code(), -32005);
203 }
204
205 #[test]
206 fn error_kind_serialises_pascal_case_per_spec() {
207 // SPEC §2.12.9 wire form is the Rust variant name verbatim.
208 let json = serde_json::to_string(&ErrorKind::StaleProtocol).unwrap();
209 assert_eq!(json, "\"StaleProtocol\"");
210 let json = serde_json::to_string(&ErrorKind::RateLimit).unwrap();
211 assert_eq!(json, "\"RateLimit\"");
212 let json = serde_json::to_string(&ErrorKind::Unauthorized).unwrap();
213 assert_eq!(json, "\"Unauthorized\"");
214 }
215
216 #[test]
217 fn rpc_error_new_round_trips_through_json() {
218 let e = RpcError::new(
219 ErrorKind::Unauthorized,
220 "manifest 'reboot' has user_invokable=false",
221 );
222 let json = serde_json::to_string(&e).unwrap();
223 let back: RpcError = serde_json::from_str(&json).unwrap();
224 assert_eq!(back.code, -32000);
225 assert_eq!(back.message, "Unauthorized");
226 let data = back.data.expect("data populated");
227 assert_eq!(data.kind, ErrorKind::Unauthorized);
228 assert_eq!(data.detail, "manifest 'reboot' has user_invokable=false");
229 }
230
231 #[test]
232 fn rpc_error_bare_round_trips_without_data_field() {
233 let e = RpcError::bare(ErrorKind::ParseError);
234 let v = serde_json::to_value(&e).unwrap();
235 // `data` SHOULD be absent on the wire (not `null`) so the
236 // envelope matches strict JSON-RPC parsers.
237 assert!(
238 v.get("data").is_none(),
239 "data field must be absent on the wire, got {v:?}",
240 );
241 assert_eq!(v["code"], -32700);
242 }
243
244 #[test]
245 fn rpc_error_spec_example_decodes() {
246 // Exact payload from SPEC §2.12.9. Pinned so a careless rename
247 // of `kind` / `detail` breaks the test loudly.
248 let wire = r#"{
249 "code": -32000,
250 "message": "Job not user-invokable",
251 "data": {"kind":"Unauthorized","detail":"manifest 'reboot' has user_invokable=false"}
252 }"#;
253 let e: RpcError = serde_json::from_str(wire).expect("decode");
254 assert_eq!(e.code, -32000);
255 let data = e.data.expect("data populated");
256 assert_eq!(data.kind, ErrorKind::Unauthorized);
257 assert!(data.detail.contains("user_invokable=false"));
258 }
259}