Skip to main content

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}