dig_rpc_types/error.rs
1//! The canonical DIG-node RPC error taxonomy.
2//!
3//! This module is the **single definition point** for every error code the DIG
4//! node RPC surface emits, the canonical [`RpcError`] envelope
5//! (`{code, message, data:{code, origin}}`), and the one constructor helper both
6//! node implementations call so every error carries a machine-branchable
7//! `data.code` and `data.origin`.
8//!
9//! # The code set
10//!
11//! Standard JSON-RPC 2.0 codes plus the DIG protocol-specific codes. The numeric
12//! values are a **published wire contract** and never change once assigned.
13//!
14//! | Code | Variant | Origin | Meaning |
15//! |---|---|---|---|
16//! | `-32700` | [`ParseError`](ErrorCode::ParseError) | Node | request body is not valid JSON |
17//! | `-32600` | [`InvalidRequest`](ErrorCode::InvalidRequest) | Node | not a valid Request object |
18//! | `-32601` | [`MethodNotFound`](ErrorCode::MethodNotFound) | Node | method not implemented |
19//! | `-32602` | [`InvalidParams`](ErrorCode::InvalidParams) | Node | missing/malformed params |
20//! | `-32603` | [`InternalError`](ErrorCode::InternalError) | Node | well-formed call failed |
21//! | `-32000` | [`ServerError`](ErrorCode::ServerError) | Node | generic server error |
22//! | `-32004` | [`ResourceUnavailable`](ErrorCode::ResourceUnavailable) | Node | resource not available at the requested root (genuine infra miss) |
23//! | `-32005` | [`RootNotAnchored`](ErrorCode::RootNotAnchored) | Node | requested/served root is not the chain-anchored root (fail-closed pin) |
24//! | `-32006` | [`PeerUnreachable`](ErrorCode::PeerUnreachable) | Node | no NAT-traversal strategy reached the peer |
25//! | `-32007` | [`RangeNotSatisfiable`](ErrorCode::RangeNotSatisfiable) | Node | byte range lies outside the resource |
26//! | `-32008` | [`ContentRedirect`](ErrorCode::ContentRedirect) | Node | content held elsewhere — `data.redirect` names holders |
27//! | `-32010` | [`UpstreamError`](ErrorCode::UpstreamError) | Upstream | an upstream/proxy fetch failed |
28//! | `-32011` | [`StageInvalidInput`](ErrorCode::StageInvalidInput) | Node | `dig.stage`: dir unreadable / walk budget exceeded |
29//! | `-32012` | [`StageNoFiles`](ErrorCode::StageNoFiles) | Node | `dig.stage`: no files to compile |
30//! | `-32013` | [`StageOverCap`](ErrorCode::StageOverCap) | Node | `dig.stage`: input exceeds the store cap |
31//! | `-32014` | [`StageCompileFailed`](ErrorCode::StageCompileFailed) | Node | `dig.stage`: compile / IO failure |
32//! | `-32020` | [`OnionCircuitUnavailable`](ErrorCode::OnionCircuitUnavailable) | Onion | private read could not build/keep a circuit |
33//! | `-32021` | [`PrivacyRequiresLocalNode`](ErrorCode::PrivacyRequiresLocalNode) | Onion | privacy mode requires the caller be a local originator |
34//! | `-32022` | [`OnionHopsOutOfRange`](ErrorCode::OnionHopsOutOfRange) | Onion | requested hop count outside `[2, 5]` |
35//! | `-32030` | [`Unauthorized`](ErrorCode::Unauthorized) | Control | control-plane call is not authorized |
36//! | `-32031` | [`NotSupported`](ErrorCode::NotSupported) | Control | control-plane method not supported here |
37//! | `-32032` | [`ControlError`](ErrorCode::ControlError) | Control | control-plane runtime error |
38//!
39//! ## The `-32020..-32022` collision, resolved
40//!
41//! The published normative protocol (docs.dig.net) assigns `-32020/-32021/-32022`
42//! to the **onion** (private-retrieval) failures. Those keep their numbers. The
43//! control-plane errors that previously squatted the same values are renumbered
44//! to `-32030/-32031/-32032`.
45
46use serde::{Deserialize, Serialize};
47use serde_repr::{Deserialize_repr, Serialize_repr};
48
49/// A canonical DIG-node RPC error code.
50///
51/// Serializes as a bare integer (via `serde_repr`), spec-compliant for
52/// `error.code`. `#[non_exhaustive]` so adding a code in a minor release is
53/// additive; downstream matches must use `_ => …`.
54#[repr(i32)]
55#[non_exhaustive]
56#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)]
57pub enum ErrorCode {
58 // ---- Standard JSON-RPC 2.0 ----
59 /// Invalid JSON was received by the server.
60 ParseError = -32700,
61 /// The JSON sent is not a valid Request object.
62 InvalidRequest = -32600,
63 /// The method does not exist / is not available.
64 MethodNotFound = -32601,
65 /// Invalid method parameter(s).
66 InvalidParams = -32602,
67 /// The node failed to satisfy a well-formed call (network profile).
68 InternalError = -32603,
69
70 // ---- DIG protocol-specific (implementation-defined server range) ----
71 /// Generic server error (config write failure, file I/O, chain read failure).
72 ServerError = -32000,
73 /// Resource not available at the requested root — a genuine infrastructure
74 /// miss (absent module, bad magic, oversize, a trap, an undecodable
75 /// envelope). Distinct from a content miss, which is an indistinguishable
76 /// decoy and is never an error.
77 ResourceUnavailable = -32004,
78 /// The requested or served generation is not the store's current on-chain
79 /// root. The read path pins to the CHIP-0035 singleton's on-chain root and
80 /// fails closed rather than serving an unverified generation.
81 RootNotAnchored = -32005,
82 /// No connection to the named peer could be established — every
83 /// NAT-traversal strategy failed, or the peer is not on this network.
84 PeerUnreachable = -32006,
85 /// The requested byte range lies outside the resource (`offset >=
86 /// total_length`) or is otherwise unsatisfiable.
87 RangeNotSatisfiable = -32007,
88 /// This node does not hold the content but located peers that do.
89 /// `data.redirect` names the holders + the redirect budget.
90 ContentRedirect = -32008,
91 /// An upstream/proxy fetch (e.g. `rpc.dig.net`) failed. Distinct from the
92 /// generic [`ServerError`](ErrorCode::ServerError) so a client can tell an
93 /// upstream fault from a local one.
94 UpstreamError = -32010,
95 /// `dig.stage`: the input directory is unreadable, or the bounded walk
96 /// exceeded its byte / file-count / depth budget.
97 StageInvalidInput = -32011,
98 /// `dig.stage`: the input directory contained no files to compile.
99 StageNoFiles = -32012,
100 /// `dig.stage`: the input exceeds the per-store size cap.
101 StageOverCap = -32013,
102 /// `dig.stage`: compiling the capsule failed (CLVM/IO error).
103 StageCompileFailed = -32014,
104
105 // ---- Onion / private retrieval (published normative — KEEP) ----
106 /// A `mode:"privacy"` read could not be served privately (no circuit could
107 /// be built, or one died mid-fetch). The node fails closed rather than
108 /// downgrading — a silent downgrade would deanonymize the reader.
109 OnionCircuitUnavailable = -32020,
110 /// `mode:"privacy"` was requested but the caller is not the node's own
111 /// trusted local originator. Privacy requires a local DIG node.
112 PrivacyRequiresLocalNode = -32021,
113 /// The requested `privacy.hops` (circuit length) is outside `[2, 5]`.
114 OnionHopsOutOfRange = -32022,
115
116 // ---- Control plane (loopback-only; renumbered off the onion codes) ----
117 /// The control-plane call is not authorized (loopback / token gate failed).
118 Unauthorized = -32030,
119 /// The control-plane method is recognized but not supported on this node.
120 NotSupported = -32031,
121 /// A control-plane runtime error (pin registry, sync trigger, config write).
122 ControlError = -32032,
123}
124
125impl ErrorCode {
126 /// The raw integer wire code.
127 pub const fn code(self) -> i32 {
128 self as i32
129 }
130
131 /// The stable `UPPER_SNAKE_CASE` machine identifier carried in `data.code`.
132 ///
133 /// This is the branch key an agent keys on; it never changes once assigned.
134 pub const fn machine_code(self) -> &'static str {
135 match self {
136 ErrorCode::ParseError => "PARSE_ERROR",
137 ErrorCode::InvalidRequest => "INVALID_REQUEST",
138 ErrorCode::MethodNotFound => "METHOD_NOT_FOUND",
139 ErrorCode::InvalidParams => "INVALID_PARAMS",
140 ErrorCode::InternalError => "INTERNAL_ERROR",
141 ErrorCode::ServerError => "SERVER_ERROR",
142 ErrorCode::ResourceUnavailable => "RESOURCE_UNAVAILABLE",
143 ErrorCode::RootNotAnchored => "ROOT_NOT_ANCHORED",
144 ErrorCode::PeerUnreachable => "PEER_UNREACHABLE",
145 ErrorCode::RangeNotSatisfiable => "RANGE_NOT_SATISFIABLE",
146 ErrorCode::ContentRedirect => "CONTENT_REDIRECT",
147 ErrorCode::UpstreamError => "UPSTREAM_ERROR",
148 ErrorCode::StageInvalidInput => "STAGE_INVALID_INPUT",
149 ErrorCode::StageNoFiles => "STAGE_NO_FILES",
150 ErrorCode::StageOverCap => "STAGE_OVER_CAP",
151 ErrorCode::StageCompileFailed => "STAGE_COMPILE_FAILED",
152 ErrorCode::OnionCircuitUnavailable => "ONION_CIRCUIT_UNAVAILABLE",
153 ErrorCode::PrivacyRequiresLocalNode => "PRIVACY_REQUIRES_LOCAL_NODE",
154 ErrorCode::OnionHopsOutOfRange => "ONION_HOPS_OUT_OF_RANGE",
155 ErrorCode::Unauthorized => "UNAUTHORIZED",
156 ErrorCode::NotSupported => "NOT_SUPPORTED",
157 ErrorCode::ControlError => "CONTROL_ERROR",
158 }
159 }
160
161 /// The default human-readable summary for this code (used when a caller does
162 /// not supply a more specific message).
163 pub const fn default_message(self) -> &'static str {
164 match self {
165 ErrorCode::ParseError => "Parse error",
166 ErrorCode::InvalidRequest => "Invalid request",
167 ErrorCode::MethodNotFound => "Method not found",
168 ErrorCode::InvalidParams => "Invalid params",
169 ErrorCode::InternalError => "Internal error",
170 ErrorCode::ServerError => "Server error",
171 ErrorCode::ResourceUnavailable => "Resource not available at the requested root",
172 ErrorCode::RootNotAnchored => "Root not chain-anchored",
173 ErrorCode::PeerUnreachable => "Peer unreachable",
174 ErrorCode::RangeNotSatisfiable => "Range not satisfiable",
175 ErrorCode::ContentRedirect => "Content held elsewhere — redirect",
176 ErrorCode::UpstreamError => "Upstream error",
177 ErrorCode::StageInvalidInput => "Stage input directory not readable",
178 ErrorCode::StageNoFiles => "Stage input contained no files",
179 ErrorCode::StageOverCap => "Stage input over the store cap",
180 ErrorCode::StageCompileFailed => "Stage compile failed",
181 ErrorCode::OnionCircuitUnavailable => "Onion circuit unavailable",
182 ErrorCode::PrivacyRequiresLocalNode => "Privacy requires a local node",
183 ErrorCode::OnionHopsOutOfRange => "Onion hop count out of range",
184 ErrorCode::Unauthorized => "Unauthorized",
185 ErrorCode::NotSupported => "Not supported",
186 ErrorCode::ControlError => "Control-plane error",
187 }
188 }
189
190 /// The natural [`ErrorOrigin`] for this code (which subsystem the failure
191 /// arose in). A caller may override it — a `ResourceUnavailable` bubbled up
192 /// from an upstream proxy can be tagged [`ErrorOrigin::Upstream`].
193 pub const fn default_origin(self) -> ErrorOrigin {
194 match self {
195 ErrorCode::UpstreamError => ErrorOrigin::Upstream,
196 ErrorCode::OnionCircuitUnavailable
197 | ErrorCode::PrivacyRequiresLocalNode
198 | ErrorCode::OnionHopsOutOfRange => ErrorOrigin::Onion,
199 ErrorCode::Unauthorized | ErrorCode::NotSupported | ErrorCode::ControlError => {
200 ErrorOrigin::Control
201 }
202 ErrorCode::PeerUnreachable => ErrorOrigin::Peer,
203 _ => ErrorOrigin::Node,
204 }
205 }
206
207 /// Whether this variant is in the JSON-RPC-reserved range
208 /// (`-32768..=-32000`).
209 pub const fn is_jsonrpc_reserved(self) -> bool {
210 let c = self.code();
211 c >= -32768 && c <= -32000
212 }
213
214 /// Every code, in wire order. Drives the OpenRPC error catalogue and the
215 /// exhaustiveness conformance test — a new variant must be added here.
216 pub const ALL: &'static [ErrorCode] = &[
217 ErrorCode::ParseError,
218 ErrorCode::InvalidRequest,
219 ErrorCode::MethodNotFound,
220 ErrorCode::InvalidParams,
221 ErrorCode::InternalError,
222 ErrorCode::ServerError,
223 ErrorCode::ResourceUnavailable,
224 ErrorCode::RootNotAnchored,
225 ErrorCode::PeerUnreachable,
226 ErrorCode::RangeNotSatisfiable,
227 ErrorCode::ContentRedirect,
228 ErrorCode::UpstreamError,
229 ErrorCode::StageInvalidInput,
230 ErrorCode::StageNoFiles,
231 ErrorCode::StageOverCap,
232 ErrorCode::StageCompileFailed,
233 ErrorCode::OnionCircuitUnavailable,
234 ErrorCode::PrivacyRequiresLocalNode,
235 ErrorCode::OnionHopsOutOfRange,
236 ErrorCode::Unauthorized,
237 ErrorCode::NotSupported,
238 ErrorCode::ControlError,
239 ];
240}
241
242/// The subsystem a failure arose in — carried in `data.origin` so a client can
243/// route the error (retry upstream, rebuild a circuit, re-auth the control
244/// plane) without parsing the message.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
246#[serde(rename_all = "lowercase")]
247pub enum ErrorOrigin {
248 /// The node's own read/serve path.
249 Node,
250 /// The peer-network layer (discovery / availability / range serving).
251 Peer,
252 /// An upstream fetch (`rpc.dig.net` proxy / whole-store sync).
253 Upstream,
254 /// The onion (private-retrieval) layer.
255 Onion,
256 /// The loopback control plane.
257 Control,
258}
259
260/// Structured error context carried in `error.data`.
261///
262/// The canonical DIG envelope always carries `data.code` (the
263/// [`UPPER_SNAKE_CASE`](ErrorCode::machine_code) machine identifier) and
264/// `data.origin`. `redirect` is present only on
265/// [`ContentRedirect`](ErrorCode::ContentRedirect); `extra` carries any
266/// method-specific fields verbatim (flattened onto `data`).
267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
268pub struct ErrorData {
269 /// The stable `UPPER_SNAKE_CASE` machine code (mirrors the numeric `code`).
270 pub code: String,
271 /// The subsystem the failure arose in.
272 pub origin: ErrorOrigin,
273 /// The redirect payload — present only on `CONTENT_REDIRECT`.
274 #[serde(skip_serializing_if = "Option::is_none", default)]
275 pub redirect: Option<crate::types::RedirectInfo>,
276 /// Any additional method-specific fields, flattened onto `data`.
277 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
278 pub extra: serde_json::Map<String, serde_json::Value>,
279}
280
281/// The canonical DIG-node RPC error object: `{code, message, data:{code, origin}}`.
282///
283/// Build one with [`RpcError::new`] (or the code-specific helpers) so the numeric
284/// code, the `data.code` machine string, and the origin can never drift apart.
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286pub struct RpcError {
287 /// The numeric wire code.
288 pub code: ErrorCode,
289 /// A human-readable message. May be refined over time; `data.code` is the
290 /// stable branch key.
291 pub message: String,
292 /// Structured, machine-branchable context.
293 pub data: ErrorData,
294}
295
296impl RpcError {
297 /// The single constructor: mint the canonical envelope from a code, a
298 /// message, and an origin. `data.code` is derived from `code` so the two
299 /// can never disagree.
300 pub fn new(code: ErrorCode, message: impl Into<String>, origin: ErrorOrigin) -> Self {
301 Self {
302 code,
303 message: message.into(),
304 data: ErrorData {
305 code: code.machine_code().to_string(),
306 origin,
307 redirect: None,
308 extra: serde_json::Map::new(),
309 },
310 }
311 }
312
313 /// Mint an error using the code's [default origin](ErrorCode::default_origin).
314 pub fn of(code: ErrorCode, message: impl Into<String>) -> Self {
315 Self::new(code, message, code.default_origin())
316 }
317
318 /// Mint an error using both the code's default origin and its
319 /// [default message](ErrorCode::default_message).
320 pub fn code_only(code: ErrorCode) -> Self {
321 Self::new(code, code.default_message(), code.default_origin())
322 }
323
324 /// Attach a [`RedirectInfo`](crate::types::RedirectInfo) payload (for
325 /// [`ContentRedirect`](ErrorCode::ContentRedirect)).
326 pub fn with_redirect(mut self, redirect: crate::types::RedirectInfo) -> Self {
327 self.data.redirect = Some(redirect);
328 self
329 }
330
331 /// Attach one extra `data` field (flattened onto `data`).
332 pub fn with_extra(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
333 self.data.extra.insert(key.into(), value);
334 self
335 }
336}
337
338impl std::fmt::Display for RpcError {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 write!(f, "[{}] {}", self.code.machine_code(), self.message)
341 }
342}
343
344impl std::error::Error for RpcError {}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 /// **Proves:** every numeric code is pinned to its published wire value.
351 /// **Catches:** a reorder/renumber, or a lost `#[repr(i32)]`.
352 #[test]
353 fn numeric_values_pinned() {
354 assert_eq!(ErrorCode::ParseError.code(), -32700);
355 assert_eq!(ErrorCode::InvalidRequest.code(), -32600);
356 assert_eq!(ErrorCode::MethodNotFound.code(), -32601);
357 assert_eq!(ErrorCode::InvalidParams.code(), -32602);
358 assert_eq!(ErrorCode::InternalError.code(), -32603);
359 assert_eq!(ErrorCode::ServerError.code(), -32000);
360 assert_eq!(ErrorCode::ResourceUnavailable.code(), -32004);
361 assert_eq!(ErrorCode::RootNotAnchored.code(), -32005);
362 assert_eq!(ErrorCode::PeerUnreachable.code(), -32006);
363 assert_eq!(ErrorCode::RangeNotSatisfiable.code(), -32007);
364 assert_eq!(ErrorCode::ContentRedirect.code(), -32008);
365 assert_eq!(ErrorCode::UpstreamError.code(), -32010);
366 assert_eq!(ErrorCode::StageInvalidInput.code(), -32011);
367 assert_eq!(ErrorCode::StageNoFiles.code(), -32012);
368 assert_eq!(ErrorCode::StageOverCap.code(), -32013);
369 assert_eq!(ErrorCode::StageCompileFailed.code(), -32014);
370 assert_eq!(ErrorCode::OnionCircuitUnavailable.code(), -32020);
371 assert_eq!(ErrorCode::PrivacyRequiresLocalNode.code(), -32021);
372 assert_eq!(ErrorCode::OnionHopsOutOfRange.code(), -32022);
373 assert_eq!(ErrorCode::Unauthorized.code(), -32030);
374 assert_eq!(ErrorCode::NotSupported.code(), -32031);
375 assert_eq!(ErrorCode::ControlError.code(), -32032);
376 }
377
378 /// **Proves:** the onion codes keep `-32020..-32022` and the control codes
379 /// are renumbered clear of them.
380 /// **Catches:** a regression that reintroduces the historical collision.
381 #[test]
382 fn onion_control_collision_resolved() {
383 assert_eq!(ErrorCode::OnionCircuitUnavailable.code(), -32020);
384 assert_eq!(ErrorCode::Unauthorized.code(), -32030);
385 assert_ne!(
386 ErrorCode::OnionCircuitUnavailable.code(),
387 ErrorCode::Unauthorized.code()
388 );
389 }
390
391 /// **Proves:** a code serializes as a bare integer, never a tagged object.
392 /// **Catches:** a swap of `Serialize_repr` for plain `Serialize`.
393 #[test]
394 fn code_serialises_as_integer() {
395 assert_eq!(
396 serde_json::to_string(&ErrorCode::MethodNotFound).unwrap(),
397 "-32601"
398 );
399 assert_eq!(
400 serde_json::to_string(&ErrorCode::ContentRedirect).unwrap(),
401 "-32008"
402 );
403 }
404
405 /// **Proves:** `ALL` lists exactly the distinct codes, with unique numbers
406 /// and unique machine strings.
407 /// **Catches:** a new variant left out of `ALL`, or a duplicated
408 /// code/machine string.
409 #[test]
410 fn all_codes_unique_and_complete() {
411 use std::collections::HashSet;
412 let nums: HashSet<i32> = ErrorCode::ALL.iter().map(|c| c.code()).collect();
413 let strs: HashSet<&str> = ErrorCode::ALL.iter().map(|c| c.machine_code()).collect();
414 assert_eq!(nums.len(), ErrorCode::ALL.len(), "duplicate numeric code");
415 assert_eq!(strs.len(), ErrorCode::ALL.len(), "duplicate machine code");
416 assert_eq!(ErrorCode::ALL.len(), 22);
417 }
418
419 /// **Proves:** the constructor mints the full `{code, message, data:{code,
420 /// origin}}` envelope and `data.code` mirrors the numeric code.
421 /// **Catches:** a drift between the numeric code and `data.code`.
422 #[test]
423 fn envelope_shape_and_data_code() {
424 let e = RpcError::new(
425 ErrorCode::ResourceUnavailable,
426 "not here",
427 ErrorOrigin::Node,
428 );
429 let v = serde_json::to_value(&e).unwrap();
430 assert_eq!(v["code"], -32004);
431 assert_eq!(v["message"], "not here");
432 assert_eq!(v["data"]["code"], "RESOURCE_UNAVAILABLE");
433 assert_eq!(v["data"]["origin"], "node");
434 // Round-trips.
435 let back: RpcError = serde_json::from_value(v).unwrap();
436 assert_eq!(back, e);
437 }
438
439 /// **Proves:** `UpstreamError` defaults to the `upstream` origin.
440 #[test]
441 fn upstream_default_origin() {
442 let e = RpcError::of(ErrorCode::UpstreamError, "upstream: boom");
443 assert_eq!(e.data.origin, ErrorOrigin::Upstream);
444 assert_eq!(
445 serde_json::to_value(&e).unwrap()["data"]["origin"],
446 "upstream"
447 );
448 }
449
450 /// **Proves:** `code_only` uses the default message + origin.
451 #[test]
452 fn code_only_defaults() {
453 let e = RpcError::code_only(ErrorCode::PeerUnreachable);
454 assert_eq!(e.message, "Peer unreachable");
455 assert_eq!(e.data.origin, ErrorOrigin::Peer);
456 }
457}