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}
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}