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}
81
82impl ErrorKind {
83 /// JSON-RPC `code` field for this kind. Pre-baked so the dispatch
84 /// path doesn't accidentally drift away from the table — the only
85 /// blessed mapping lives here.
86 pub fn code(self) -> i32 {
87 match self {
88 Self::ParseError => -32700,
89 Self::InvalidRequest => -32600,
90 Self::MethodNotFound => -32601,
91 Self::InvalidParams => -32602,
92 Self::InternalError => -32603,
93 Self::Unauthorized => -32000,
94 Self::NotFound => -32001,
95 Self::AgentDisconnected => -32002,
96 Self::RateLimit => -32003,
97 Self::StaleProtocol => -32004,
98 Self::PayloadTooLarge => -32005,
99 }
100 }
101
102 /// Default human-readable `message`. Agents may override per-call
103 /// when they have a more specific phrasing; tests and the spec
104 /// table use these.
105 pub fn default_message(self) -> &'static str {
106 match self {
107 Self::ParseError => "Parse error",
108 Self::InvalidRequest => "Invalid Request",
109 Self::MethodNotFound => "Method not found",
110 Self::InvalidParams => "Invalid params",
111 Self::InternalError => "Internal error",
112 Self::Unauthorized => "Unauthorized",
113 Self::NotFound => "Not found",
114 Self::AgentDisconnected => "Agent disconnected from broker",
115 Self::RateLimit => "Rate limit exceeded",
116 Self::StaleProtocol => "Stale protocol version",
117 Self::PayloadTooLarge => "Payload too large",
118 }
119 }
120}
121
122/// JSON-RPC 2.0 `error` object (SPEC §2.12.9). Always paired with a
123/// non-null `id` inside an [`super::envelope::RpcResponse`] — there
124/// is no notion of an error notification in KLP.
125#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
126pub struct RpcError {
127 pub code: i32,
128 pub message: String,
129 /// Structured detail. Wire-optional so the JSON-RPC reserved
130 /// codes (-32600 / -32601 / -32602 / -32603 / -32700) can be
131 /// returned with just `{code, message}` when the agent has no
132 /// extra context.
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub data: Option<RpcErrorData>,
135}
136
137/// Application-side payload inside [`RpcError::data`].
138#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
139pub struct RpcErrorData {
140 pub kind: ErrorKind,
141 /// Free-form human-readable elaboration. Safe to surface in the
142 /// SPA toast; never carries secrets (agent code redacts before
143 /// constructing).
144 pub detail: String,
145}
146
147impl RpcError {
148 /// Build an error matching SPEC §2.12.9's canonical shape:
149 /// `code` derived from `kind`, `message` defaulted from `kind`,
150 /// `data` populated with `{kind, detail}`.
151 pub fn new(kind: ErrorKind, detail: impl Into<String>) -> Self {
152 Self {
153 code: kind.code(),
154 message: kind.default_message().to_string(),
155 data: Some(RpcErrorData {
156 kind,
157 detail: detail.into(),
158 }),
159 }
160 }
161
162 /// Bare error without `data`. Use only for the JSON-RPC reserved
163 /// codes where the spec allows omitting structured detail (parse
164 /// errors before the envelope can be decoded, etc.).
165 pub fn bare(kind: ErrorKind) -> Self {
166 Self {
167 code: kind.code(),
168 message: kind.default_message().to_string(),
169 data: None,
170 }
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn error_kind_codes_match_spec_table() {
180 // SPEC §2.12.9 — pinned so a refactor of the table can't
181 // silently drift codes apart.
182 assert_eq!(ErrorKind::ParseError.code(), -32700);
183 assert_eq!(ErrorKind::InvalidRequest.code(), -32600);
184 assert_eq!(ErrorKind::MethodNotFound.code(), -32601);
185 assert_eq!(ErrorKind::InvalidParams.code(), -32602);
186 assert_eq!(ErrorKind::InternalError.code(), -32603);
187 assert_eq!(ErrorKind::Unauthorized.code(), -32000);
188 assert_eq!(ErrorKind::NotFound.code(), -32001);
189 assert_eq!(ErrorKind::AgentDisconnected.code(), -32002);
190 assert_eq!(ErrorKind::RateLimit.code(), -32003);
191 assert_eq!(ErrorKind::StaleProtocol.code(), -32004);
192 assert_eq!(ErrorKind::PayloadTooLarge.code(), -32005);
193 }
194
195 #[test]
196 fn error_kind_serialises_pascal_case_per_spec() {
197 // SPEC §2.12.9 wire form is the Rust variant name verbatim.
198 let json = serde_json::to_string(&ErrorKind::StaleProtocol).unwrap();
199 assert_eq!(json, "\"StaleProtocol\"");
200 let json = serde_json::to_string(&ErrorKind::RateLimit).unwrap();
201 assert_eq!(json, "\"RateLimit\"");
202 let json = serde_json::to_string(&ErrorKind::Unauthorized).unwrap();
203 assert_eq!(json, "\"Unauthorized\"");
204 }
205
206 #[test]
207 fn rpc_error_new_round_trips_through_json() {
208 let e = RpcError::new(
209 ErrorKind::Unauthorized,
210 "manifest 'reboot' has user_invokable=false",
211 );
212 let json = serde_json::to_string(&e).unwrap();
213 let back: RpcError = serde_json::from_str(&json).unwrap();
214 assert_eq!(back.code, -32000);
215 assert_eq!(back.message, "Unauthorized");
216 let data = back.data.expect("data populated");
217 assert_eq!(data.kind, ErrorKind::Unauthorized);
218 assert_eq!(data.detail, "manifest 'reboot' has user_invokable=false");
219 }
220
221 #[test]
222 fn rpc_error_bare_round_trips_without_data_field() {
223 let e = RpcError::bare(ErrorKind::ParseError);
224 let v = serde_json::to_value(&e).unwrap();
225 // `data` SHOULD be absent on the wire (not `null`) so the
226 // envelope matches strict JSON-RPC parsers.
227 assert!(
228 v.get("data").is_none(),
229 "data field must be absent on the wire, got {v:?}",
230 );
231 assert_eq!(v["code"], -32700);
232 }
233
234 #[test]
235 fn rpc_error_spec_example_decodes() {
236 // Exact payload from SPEC §2.12.9. Pinned so a careless rename
237 // of `kind` / `detail` breaks the test loudly.
238 let wire = r#"{
239 "code": -32000,
240 "message": "Job not user-invokable",
241 "data": {"kind":"Unauthorized","detail":"manifest 'reboot' has user_invokable=false"}
242 }"#;
243 let e: RpcError = serde_json::from_str(wire).expect("decode");
244 assert_eq!(e.code, -32000);
245 let data = e.data.expect("data populated");
246 assert_eq!(data.kind, ErrorKind::Unauthorized);
247 assert!(data.detail.contains("user_invokable=false"));
248 }
249}