Skip to main content

dig_rpc_protocol/
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//! | `-32003` | [`ContentMissRateLimited`](ErrorCode::ContentMissRateLimited) | Node | content not held; miss-lookup budget exhausted for this requestor |
23//! | `-32004` | [`ResourceUnavailable`](ErrorCode::ResourceUnavailable) | Node | resource not available at the requested root (genuine infra miss) |
24//! | `-32005` | [`RootNotAnchored`](ErrorCode::RootNotAnchored) | Node | requested/served root is not the chain-anchored root (fail-closed pin) |
25//! | `-32006` | [`PeerUnreachable`](ErrorCode::PeerUnreachable) | Node | no NAT-traversal strategy reached the peer |
26//! | `-32007` | [`RangeNotSatisfiable`](ErrorCode::RangeNotSatisfiable) | Node | byte range lies outside the resource |
27//! | `-32008` | [`ContentRedirect`](ErrorCode::ContentRedirect) | Node | content held elsewhere — `data.redirect` names holders |
28//! | `-32009` | [`RangeMetadataUnrepresentable`](ErrorCode::RangeMetadataUnrepresentable) | Node | the resource's own range metadata cannot fit a conforming frame, so this holder can NEVER serve the range |
29//! | `-32010` | [`UpstreamError`](ErrorCode::UpstreamError) | Upstream | an upstream/proxy fetch failed |
30//! | `-32011` | [`StageInvalidInput`](ErrorCode::StageInvalidInput) | Node | `dig.stage`: dir unreadable / walk budget exceeded |
31//! | `-32012` | [`StageNoFiles`](ErrorCode::StageNoFiles) | Node | `dig.stage`: no files to compile |
32//! | `-32013` | [`StageOverCap`](ErrorCode::StageOverCap) | Node | `dig.stage`: input exceeds the store cap |
33//! | `-32014` | [`StageCompileFailed`](ErrorCode::StageCompileFailed) | Node | `dig.stage`: compile / IO failure |
34//! | `-32015` | [`MetadataTooLarge`](ErrorCode::MetadataTooLarge) | Node | `dig.getMetadata`: the metadata section is refused as too large/complex (bounded error) |
35//! | `-32016` | [`PushPendingLimited`](ErrorCode::PushPendingLimited) | Node | `cache.pushCapsule`: refused, the pending-push budget is exhausted |
36//! | `-32017` | [`ContentMissInconclusive`](ErrorCode::ContentMissInconclusive) | Peer | absence was NOT established — a hop timed out, was unreachable, or refused uninformatively |
37//! | `-32020` | [`OnionCircuitUnavailable`](ErrorCode::OnionCircuitUnavailable) | Onion | private read could not build/keep a circuit |
38//! | `-32021` | [`PrivacyRequiresLocalNode`](ErrorCode::PrivacyRequiresLocalNode) | Onion | privacy mode requires the caller be a local originator |
39//! | `-32022` | [`OnionHopsOutOfRange`](ErrorCode::OnionHopsOutOfRange) | Onion | requested hop count outside `[2, 5]` |
40//! | `-32030` | [`Unauthorized`](ErrorCode::Unauthorized) | Control | control-plane call is not authorized |
41//! | `-32031` | [`NotSupported`](ErrorCode::NotSupported) | Control | control-plane method not supported here |
42//! | `-32032` | [`ControlError`](ErrorCode::ControlError) | Control | control-plane runtime error |
43//! | `-32050` | [`NoIdentity`](ErrorCode::NoIdentity) | Node | no persistent identity key, so the node cannot seal as sender |
44//! | `-32051` | [`NoPeerNetwork`](ErrorCode::NoPeerNetwork) | Peer | no gossip pool, so a directed send has no transport |
45//! | `-32052` | [`SendFailed`](ErrorCode::SendFailed) | Peer | sealing or sending the directed message failed |
46//!
47//! ## The `-32020..-32022` collision, resolved
48//!
49//! The published normative protocol (docs.dig.net) assigns `-32020/-32021/-32022`
50//! to the **onion** (private-retrieval) failures. Those keep their numbers. The
51//! control-plane errors that previously squatted the same values are renumbered
52//! to `-32030/-32031/-32032`.
53//!
54//! ## The canonical space includes consumer-held ranges
55//!
56//! A number is **free only if it is unoccupied ECOSYSTEM-WIDE**, not merely absent
57//! from [`ErrorCode::ALL`]. Consumers hold undeclared bands inside this same space:
58//! `-32015` `METADATA_TOO_LARGE` and `-32016` `PUSH_PENDING_LIMITED` were released,
59//! catalogued on docs.dig.net, and emitted by dig-node while being invisible here,
60//! so `ContentMissInconclusive` was assigned `-32015` as "the next free code" and
61//! collided with a live wire contract. That was the second such collision on one
62//! variant (`-32009` was the first).
63//!
64//! Those five consumer-held codes are now DECLARED here with their existing numbers
65//! and meanings unchanged — the canonical side adapts, because a released code is a
66//! published branch key and renumbering one breaks clients (§5.1 is additive-only).
67//! The band map, so the next assignment has somewhere to look:
68//!
69//! | Band | Owner |
70//! |---|---|
71//! | `-32000..-32019` | node read/serve + staging |
72//! | `-32020..-32029` | onion / private retrieval |
73//! | `-32030..-32039` | loopback control plane |
74//! | `-32040..-32049` | control-plane wallet reads (consumer-held, not yet absorbed) |
75//! | `-32050..-32059` | directed messaging — sealed sender-to-recipient sends |
76//!
77//! `-32050..-32052` get a band of their own rather than joining the control band:
78//! they are served on the node's ORDINARY JSON-RPC surface (dispatched from
79//! `seams::dig_rpc`, reusing the standard `-32602` for bad params), so they are
80//! neither control-plane nor "the node's private application range" — a directed
81//! send is a peer-network operation and is banded as one.
82//!
83//! `tests/error_code_registry.rs` holds the measured occupancy as data and asserts
84//! no declaration contradicts it. See that file for what the list can and cannot
85//! prove.
86
87use serde::{Deserialize, Serialize};
88use serde_repr::{Deserialize_repr, Serialize_repr};
89
90/// A canonical DIG-node RPC error code.
91///
92/// Serializes as a bare integer (via `serde_repr`), spec-compliant for
93/// `error.code`. `#[non_exhaustive]` so adding a code in a minor release is
94/// additive; downstream matches must use `_ => …`.
95#[repr(i32)]
96#[non_exhaustive]
97#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)]
98pub enum ErrorCode {
99    // ---- Standard JSON-RPC 2.0 ----
100    /// Invalid JSON was received by the server.
101    ParseError = -32700,
102    /// The JSON sent is not a valid Request object.
103    InvalidRequest = -32600,
104    /// The method does not exist / is not available.
105    MethodNotFound = -32601,
106    /// Invalid method parameter(s).
107    InvalidParams = -32602,
108    /// The node failed to satisfy a well-formed call (network profile).
109    InternalError = -32603,
110
111    // ---- DIG protocol-specific (implementation-defined server range) ----
112    /// Generic server error (config write failure, file I/O, chain read failure).
113    ServerError = -32000,
114    /// This node does not hold the content, and the requestor has exhausted its
115    /// miss-lookup budget. Unlike an ordinary content miss (an indistinguishable
116    /// decoy that is never an error), this is an explicit rate-limit signal: the
117    /// requestor has spent its budget of miss lookups and should back off rather
118    /// than keep probing.
119    ContentMissRateLimited = -32003,
120    /// Resource not available at the requested root — a genuine infrastructure
121    /// miss (absent module, bad magic, oversize, a trap, an undecodable
122    /// envelope). Distinct from a content miss, which is an indistinguishable
123    /// decoy and is never an error.
124    ResourceUnavailable = -32004,
125    /// The requested or served generation is not the store's current on-chain
126    /// root. The read path pins to the CHIP-0035 singleton's on-chain root and
127    /// fails closed rather than serving an unverified generation.
128    RootNotAnchored = -32005,
129    /// No connection to the named peer could be established — every
130    /// NAT-traversal strategy failed, or the peer is not on this network.
131    PeerUnreachable = -32006,
132    /// The requested byte range lies outside the resource (`offset >=
133    /// total_length`) or is otherwise unsatisfiable.
134    RangeNotSatisfiable = -32007,
135    /// This node does not hold the content but located peers that do.
136    /// `data.redirect` names the holders + the redirect budget.
137    ContentRedirect = -32008,
138    /// `dig.fetchRange`: the resource's own range metadata cannot be represented
139    /// in a conforming frame at all, so this holder can NEVER serve the range.
140    ///
141    /// A resource whose `chunk_lens` layout or `inclusion_proof` exceeds the
142    /// per-frame bounds has no conforming first frame, even with a paged prologue.
143    /// That is a permanent property of the resource, not a transient condition, so
144    /// it needs its own code: a client that could not tell it from an ordinary
145    /// transport failure would keep retrying a holder that cannot succeed, and would
146    /// retry every other holder of the same resource for the same reason.
147    RangeMetadataUnrepresentable = -32009,
148    /// An upstream/proxy fetch (e.g. `rpc.dig.net`) failed. Distinct from the
149    /// generic [`ServerError`](ErrorCode::ServerError) so a client can tell an
150    /// upstream fault from a local one.
151    UpstreamError = -32010,
152    /// `dig.stage`: the input directory is unreadable, or the bounded walk
153    /// exceeded its byte / file-count / depth budget.
154    StageInvalidInput = -32011,
155    /// `dig.stage`: the input directory contained no files to compile.
156    StageNoFiles = -32012,
157    /// `dig.stage`: the input exceeds the per-store size cap.
158    StageOverCap = -32013,
159    /// `dig.stage`: compiling the capsule failed (CLVM/IO error).
160    StageCompileFailed = -32014,
161
162    /// `dig.getMetadata`: the capsule's publisher metadata section is refused
163    /// because it renders larger than the bounded response ceiling, or is too
164    /// complex (more custom entries than the cap allows).
165    ///
166    /// The section is returned WHOLE — it cannot be paged like content — and its
167    /// `links`/`custom` fields are publisher-controlled, so an oversized section is
168    /// refused with this bounded error rather than returned as one very large
169    /// response. A normal store's metadata is well inside the ceiling.
170    ///
171    /// Declared here because dig-node has emitted it, and docs.dig.net has
172    /// catalogued it, since before this taxonomy existed.
173    MetadataTooLarge = -32015,
174    /// `cache.pushCapsule`: this window is refused because accepting it would
175    /// exceed an in-flight reassembly bound — the per-requestor concurrent-push
176    /// cap, the global concurrent-push cap, or the global pending-bytes budget.
177    ///
178    /// Retriable: the budget frees as in-flight pushes complete or an abandoned
179    /// partial is reaped. Distinct from
180    /// [`MetadataTooLarge`](ErrorCode::MetadataTooLarge), which is a bound on ONE
181    /// response, and from
182    /// [`ContentMissRateLimited`](ErrorCode::ContentMissRateLimited), which is a
183    /// per-requestor lookup budget rather than a memory bound.
184    ///
185    /// Declared here for the same reason as `-32015`: it was already released.
186    PushPendingLimited = -32016,
187
188    /// `dig.getAvailability`: this node could not ESTABLISH that nobody holds the
189    /// content. A recursive ask reached a hop that timed out, was unreachable, or
190    /// refused in a way that carries no information about the content, so the
191    /// subtree behind that hop was never actually consulted.
192    ///
193    /// It is deliberately NOT a miss. A plain miss (`available: false` with
194    /// [`absence_established`](crate::types::AvailabilityAnswer::absence_established)
195    /// true) is a positive assertion: the responder looked and nobody it can reach
196    /// holds this. This code is the absence of that assertion.
197    ///
198    /// A client MUST NOT treat it as absence — give up the search and report
199    /// not-found — and MUST NOT treat it as holder-fatal. Keep looking, and this
200    /// holder stays eligible for a later ask.
201    ///
202    /// # Why `-32017`, after two collisions
203    ///
204    /// [`RangeMetadataUnrepresentable`](ErrorCode::RangeMetadataUnrepresentable) is
205    /// holder-FATAL: that holder can never serve that range, so a client should
206    /// never ask it again. This code is the opposite instruction on both axes —
207    /// nothing is established and nothing is exhausted. A client that could not tell
208    /// them apart would either permanently blacklist a merely-uncertain holder or
209    /// keep re-asking one that can never serve.
210    ///
211    /// It was then written as `-32015`, verified free against THIS crate's list —
212    /// where it was free — while `-32015` is dig-node's released
213    /// [`MetadataTooLarge`](ErrorCode::MetadataTooLarge). `-32017` was chosen only
214    /// after measuring zero occurrences ecosystem-wide.
215    ContentMissInconclusive = -32017,
216
217    // ---- Onion / private retrieval (published normative — KEEP) ----
218    /// A `mode:"privacy"` read could not be served privately (no circuit could
219    /// be built, or one died mid-fetch). The node fails closed rather than
220    /// downgrading — a silent downgrade would deanonymize the reader.
221    OnionCircuitUnavailable = -32020,
222    /// `mode:"privacy"` was requested but the caller is not the node's own
223    /// trusted local originator. Privacy requires a local DIG node.
224    PrivacyRequiresLocalNode = -32021,
225    /// The requested `privacy.hops` (circuit length) is outside `[2, 5]`.
226    OnionHopsOutOfRange = -32022,
227
228    // ---- Control plane (loopback-only; renumbered off the onion codes) ----
229    /// The control-plane call is not authorized (loopback / token gate failed).
230    Unauthorized = -32030,
231    /// The control-plane method is recognized but not supported on this node.
232    NotSupported = -32031,
233    /// A control-plane runtime error (pin registry, sync trigger, config write).
234    ControlError = -32032,
235
236    // ---- Directed messaging (sealed sender-to-recipient sends) ----
237    /// A directed send was refused because this node has no persistent identity
238    /// key, so it cannot seal as sender.
239    ///
240    /// NC-1 requires a directed message be sealed to the recipient BY the sender's
241    /// identity; without a persistent key there is no sender to seal as, and the
242    /// node refuses rather than sending something the recipient cannot attribute.
243    NoIdentity = -32050,
244    /// A directed send was refused because this node has no gossip pool — there is
245    /// no peer transport to carry the message to the recipient.
246    NoPeerNetwork = -32051,
247    /// Sealing or sending the directed message failed.
248    SendFailed = -32052,
249}
250
251impl ErrorCode {
252    /// The raw integer wire code.
253    pub const fn code(self) -> i32 {
254        self as i32
255    }
256
257    /// The stable `UPPER_SNAKE_CASE` machine identifier carried in `data.code`.
258    ///
259    /// This is the branch key an agent keys on; it never changes once assigned.
260    pub const fn machine_code(self) -> &'static str {
261        match self {
262            ErrorCode::ParseError => "PARSE_ERROR",
263            ErrorCode::InvalidRequest => "INVALID_REQUEST",
264            ErrorCode::MethodNotFound => "METHOD_NOT_FOUND",
265            ErrorCode::InvalidParams => "INVALID_PARAMS",
266            ErrorCode::InternalError => "INTERNAL_ERROR",
267            ErrorCode::ServerError => "SERVER_ERROR",
268            ErrorCode::ContentMissRateLimited => "CONTENT_MISS_RATE_LIMITED",
269            ErrorCode::ResourceUnavailable => "RESOURCE_UNAVAILABLE",
270            ErrorCode::RootNotAnchored => "ROOT_NOT_ANCHORED",
271            ErrorCode::PeerUnreachable => "PEER_UNREACHABLE",
272            ErrorCode::RangeNotSatisfiable => "RANGE_NOT_SATISFIABLE",
273            ErrorCode::ContentRedirect => "CONTENT_REDIRECT",
274            ErrorCode::RangeMetadataUnrepresentable => "RANGE_METADATA_UNREPRESENTABLE",
275            ErrorCode::UpstreamError => "UPSTREAM_ERROR",
276            ErrorCode::StageInvalidInput => "STAGE_INVALID_INPUT",
277            ErrorCode::StageNoFiles => "STAGE_NO_FILES",
278            ErrorCode::StageOverCap => "STAGE_OVER_CAP",
279            ErrorCode::StageCompileFailed => "STAGE_COMPILE_FAILED",
280            ErrorCode::MetadataTooLarge => "METADATA_TOO_LARGE",
281            ErrorCode::PushPendingLimited => "PUSH_PENDING_LIMITED",
282            ErrorCode::ContentMissInconclusive => "CONTENT_MISS_INCONCLUSIVE",
283            ErrorCode::OnionCircuitUnavailable => "ONION_CIRCUIT_UNAVAILABLE",
284            ErrorCode::PrivacyRequiresLocalNode => "PRIVACY_REQUIRES_LOCAL_NODE",
285            ErrorCode::OnionHopsOutOfRange => "ONION_HOPS_OUT_OF_RANGE",
286            ErrorCode::Unauthorized => "UNAUTHORIZED",
287            ErrorCode::NotSupported => "NOT_SUPPORTED",
288            ErrorCode::ControlError => "CONTROL_ERROR",
289            ErrorCode::NoIdentity => "NO_IDENTITY",
290            ErrorCode::NoPeerNetwork => "NO_PEER_NETWORK",
291            ErrorCode::SendFailed => "SEND_FAILED",
292        }
293    }
294
295    /// The default human-readable summary for this code (used when a caller does
296    /// not supply a more specific message).
297    pub const fn default_message(self) -> &'static str {
298        match self {
299            ErrorCode::ParseError => "Parse error",
300            ErrorCode::InvalidRequest => "Invalid request",
301            ErrorCode::MethodNotFound => "Method not found",
302            ErrorCode::InvalidParams => "Invalid params",
303            ErrorCode::InternalError => "Internal error",
304            ErrorCode::ServerError => "Server error",
305            ErrorCode::ContentMissRateLimited => {
306                "Content not held; miss-lookup budget exhausted for this requestor"
307            }
308            ErrorCode::ResourceUnavailable => "Resource not available at the requested root",
309            ErrorCode::RootNotAnchored => "Root not chain-anchored",
310            ErrorCode::PeerUnreachable => "Peer unreachable",
311            ErrorCode::RangeNotSatisfiable => "Range not satisfiable",
312            ErrorCode::ContentRedirect => "Content held elsewhere — redirect",
313            ErrorCode::RangeMetadataUnrepresentable => {
314                "Range metadata cannot be represented in a conforming frame"
315            }
316            ErrorCode::UpstreamError => "Upstream error",
317            ErrorCode::StageInvalidInput => "Stage input directory not readable",
318            ErrorCode::StageNoFiles => "Stage input contained no files",
319            ErrorCode::StageOverCap => "Stage input over the store cap",
320            ErrorCode::StageCompileFailed => "Stage compile failed",
321            ErrorCode::MetadataTooLarge => "Metadata section too large to return",
322            ErrorCode::PushPendingLimited => "Push pending-reassembly limit reached",
323            ErrorCode::ContentMissInconclusive => {
324                "Absence not established — a hop could not be consulted"
325            }
326            ErrorCode::OnionCircuitUnavailable => "Onion circuit unavailable",
327            ErrorCode::PrivacyRequiresLocalNode => "Privacy requires a local node",
328            ErrorCode::OnionHopsOutOfRange => "Onion hop count out of range",
329            ErrorCode::Unauthorized => "Unauthorized",
330            ErrorCode::NotSupported => "Not supported",
331            ErrorCode::ControlError => "Control-plane error",
332            ErrorCode::NoIdentity => "No node identity key — cannot seal as sender",
333            ErrorCode::NoPeerNetwork => "No peer network — a directed send has no transport",
334            ErrorCode::SendFailed => "Seal or send failed",
335        }
336    }
337
338    /// The natural [`ErrorOrigin`] for this code (which subsystem the failure
339    /// arose in). A caller may override it — a `ResourceUnavailable` bubbled up
340    /// from an upstream proxy can be tagged [`ErrorOrigin::Upstream`].
341    pub const fn default_origin(self) -> ErrorOrigin {
342        match self {
343            ErrorCode::UpstreamError => ErrorOrigin::Upstream,
344            ErrorCode::OnionCircuitUnavailable
345            | ErrorCode::PrivacyRequiresLocalNode
346            | ErrorCode::OnionHopsOutOfRange => ErrorOrigin::Onion,
347            ErrorCode::Unauthorized | ErrorCode::NotSupported | ErrorCode::ControlError => {
348                ErrorOrigin::Control
349            }
350            ErrorCode::PeerUnreachable
351            | ErrorCode::ContentMissInconclusive
352            | ErrorCode::NoPeerNetwork
353            | ErrorCode::SendFailed => ErrorOrigin::Peer,
354            _ => ErrorOrigin::Node,
355        }
356    }
357
358    /// Whether this variant is in the JSON-RPC-reserved range
359    /// (`-32768..=-32000`).
360    pub const fn is_jsonrpc_reserved(self) -> bool {
361        let c = self.code();
362        c >= -32768 && c <= -32000
363    }
364
365    /// Every code, in wire order. Drives the OpenRPC error catalogue and the
366    /// exhaustiveness conformance test — a new variant must be added here.
367    pub const ALL: &'static [ErrorCode] = &[
368        ErrorCode::ParseError,
369        ErrorCode::InvalidRequest,
370        ErrorCode::MethodNotFound,
371        ErrorCode::InvalidParams,
372        ErrorCode::InternalError,
373        ErrorCode::ServerError,
374        ErrorCode::ContentMissRateLimited,
375        ErrorCode::ResourceUnavailable,
376        ErrorCode::RootNotAnchored,
377        ErrorCode::PeerUnreachable,
378        ErrorCode::RangeNotSatisfiable,
379        ErrorCode::ContentRedirect,
380        ErrorCode::RangeMetadataUnrepresentable,
381        ErrorCode::UpstreamError,
382        ErrorCode::StageInvalidInput,
383        ErrorCode::StageNoFiles,
384        ErrorCode::StageOverCap,
385        ErrorCode::StageCompileFailed,
386        ErrorCode::MetadataTooLarge,
387        ErrorCode::PushPendingLimited,
388        ErrorCode::ContentMissInconclusive,
389        ErrorCode::OnionCircuitUnavailable,
390        ErrorCode::PrivacyRequiresLocalNode,
391        ErrorCode::OnionHopsOutOfRange,
392        ErrorCode::Unauthorized,
393        ErrorCode::NotSupported,
394        ErrorCode::ControlError,
395        ErrorCode::NoIdentity,
396        ErrorCode::NoPeerNetwork,
397        ErrorCode::SendFailed,
398    ];
399}
400
401/// The subsystem a failure arose in — carried in `data.origin` so a client can
402/// route the error (retry upstream, rebuild a circuit, re-auth the control
403/// plane) without parsing the message.
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
405#[serde(rename_all = "lowercase")]
406pub enum ErrorOrigin {
407    /// The node's own read/serve path.
408    Node,
409    /// The peer-network layer (discovery / availability / range serving).
410    Peer,
411    /// An upstream fetch (`rpc.dig.net` proxy / whole-store sync).
412    Upstream,
413    /// The onion (private-retrieval) layer.
414    Onion,
415    /// The loopback control plane.
416    Control,
417}
418
419/// Structured error context carried in `error.data`.
420///
421/// The canonical DIG envelope always carries `data.code` (the
422/// [`UPPER_SNAKE_CASE`](ErrorCode::machine_code) machine identifier) and
423/// `data.origin`. `redirect` is present only on
424/// [`ContentRedirect`](ErrorCode::ContentRedirect); `extra` carries any
425/// method-specific fields verbatim (flattened onto `data`).
426#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
427pub struct ErrorData {
428    /// The stable `UPPER_SNAKE_CASE` machine code (mirrors the numeric `code`).
429    pub code: String,
430    /// The subsystem the failure arose in.
431    pub origin: ErrorOrigin,
432    /// The redirect payload — present only on `CONTENT_REDIRECT`.
433    #[serde(skip_serializing_if = "Option::is_none", default)]
434    pub redirect: Option<crate::types::RedirectInfo>,
435    /// Any additional method-specific fields, flattened onto `data`.
436    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
437    pub extra: serde_json::Map<String, serde_json::Value>,
438}
439
440/// The canonical DIG-node RPC error object: `{code, message, data:{code, origin}}`.
441///
442/// Build one with [`RpcError::new`] (or the code-specific helpers) so the numeric
443/// code, the `data.code` machine string, and the origin can never drift apart.
444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
445pub struct RpcError {
446    /// The numeric wire code.
447    pub code: ErrorCode,
448    /// A human-readable message. May be refined over time; `data.code` is the
449    /// stable branch key.
450    pub message: String,
451    /// Structured, machine-branchable context.
452    pub data: ErrorData,
453}
454
455impl RpcError {
456    /// The single constructor: mint the canonical envelope from a code, a
457    /// message, and an origin. `data.code` is derived from `code` so the two
458    /// can never disagree.
459    pub fn new(code: ErrorCode, message: impl Into<String>, origin: ErrorOrigin) -> Self {
460        Self {
461            code,
462            message: message.into(),
463            data: ErrorData {
464                code: code.machine_code().to_string(),
465                origin,
466                redirect: None,
467                extra: serde_json::Map::new(),
468            },
469        }
470    }
471
472    /// Mint an error using the code's [default origin](ErrorCode::default_origin).
473    pub fn of(code: ErrorCode, message: impl Into<String>) -> Self {
474        Self::new(code, message, code.default_origin())
475    }
476
477    /// Mint an error using both the code's default origin and its
478    /// [default message](ErrorCode::default_message).
479    pub fn code_only(code: ErrorCode) -> Self {
480        Self::new(code, code.default_message(), code.default_origin())
481    }
482
483    /// Attach a [`RedirectInfo`](crate::types::RedirectInfo) payload (for
484    /// [`ContentRedirect`](ErrorCode::ContentRedirect)).
485    pub fn with_redirect(mut self, redirect: crate::types::RedirectInfo) -> Self {
486        self.data.redirect = Some(redirect);
487        self
488    }
489
490    /// Attach one extra `data` field (flattened onto `data`).
491    pub fn with_extra(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
492        self.data.extra.insert(key.into(), value);
493        self
494    }
495}
496
497impl std::fmt::Display for RpcError {
498    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        write!(f, "[{}] {}", self.code.machine_code(), self.message)
500    }
501}
502
503impl std::error::Error for RpcError {}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    /// **Proves:** every numeric code is pinned to its published wire value.
510    /// **Catches:** a reorder/renumber, or a lost `#[repr(i32)]`.
511    #[test]
512    fn numeric_values_pinned() {
513        assert_eq!(ErrorCode::ParseError.code(), -32700);
514        assert_eq!(ErrorCode::InvalidRequest.code(), -32600);
515        assert_eq!(ErrorCode::MethodNotFound.code(), -32601);
516        assert_eq!(ErrorCode::InvalidParams.code(), -32602);
517        assert_eq!(ErrorCode::InternalError.code(), -32603);
518        assert_eq!(ErrorCode::ServerError.code(), -32000);
519        assert_eq!(ErrorCode::ContentMissRateLimited.code(), -32003);
520        assert_eq!(ErrorCode::ResourceUnavailable.code(), -32004);
521        assert_eq!(ErrorCode::RootNotAnchored.code(), -32005);
522        assert_eq!(ErrorCode::PeerUnreachable.code(), -32006);
523        assert_eq!(ErrorCode::RangeNotSatisfiable.code(), -32007);
524        assert_eq!(ErrorCode::ContentRedirect.code(), -32008);
525        assert_eq!(ErrorCode::UpstreamError.code(), -32010);
526        assert_eq!(ErrorCode::StageInvalidInput.code(), -32011);
527        assert_eq!(ErrorCode::StageNoFiles.code(), -32012);
528        assert_eq!(ErrorCode::StageOverCap.code(), -32013);
529        assert_eq!(ErrorCode::StageCompileFailed.code(), -32014);
530        assert_eq!(ErrorCode::MetadataTooLarge.code(), -32015);
531        assert_eq!(ErrorCode::PushPendingLimited.code(), -32016);
532        assert_eq!(ErrorCode::ContentMissInconclusive.code(), -32017);
533        assert_eq!(ErrorCode::OnionCircuitUnavailable.code(), -32020);
534        assert_eq!(ErrorCode::PrivacyRequiresLocalNode.code(), -32021);
535        assert_eq!(ErrorCode::OnionHopsOutOfRange.code(), -32022);
536        assert_eq!(ErrorCode::Unauthorized.code(), -32030);
537        assert_eq!(ErrorCode::NotSupported.code(), -32031);
538        assert_eq!(ErrorCode::ControlError.code(), -32032);
539        assert_eq!(ErrorCode::NoIdentity.code(), -32050);
540        assert_eq!(ErrorCode::NoPeerNetwork.code(), -32051);
541        assert_eq!(ErrorCode::SendFailed.code(), -32052);
542    }
543
544    /// **Proves:** the onion codes keep `-32020..-32022` and the control codes
545    /// are renumbered clear of them.
546    /// **Catches:** a regression that reintroduces the historical collision.
547    #[test]
548    fn onion_control_collision_resolved() {
549        assert_eq!(ErrorCode::OnionCircuitUnavailable.code(), -32020);
550        assert_eq!(ErrorCode::Unauthorized.code(), -32030);
551        assert_ne!(
552            ErrorCode::OnionCircuitUnavailable.code(),
553            ErrorCode::Unauthorized.code()
554        );
555    }
556
557    /// **Proves:** a code serializes as a bare integer, never a tagged object.
558    /// **Catches:** a swap of `Serialize_repr` for plain `Serialize`.
559    #[test]
560    fn code_serialises_as_integer() {
561        assert_eq!(
562            serde_json::to_string(&ErrorCode::MethodNotFound).unwrap(),
563            "-32601"
564        );
565        assert_eq!(
566            serde_json::to_string(&ErrorCode::ContentRedirect).unwrap(),
567            "-32008"
568        );
569    }
570
571    /// **Proves:** `ALL` lists exactly the distinct codes, with unique numbers
572    /// and unique machine strings.
573    /// **Catches:** a new variant left out of `ALL`, or a duplicated
574    /// code/machine string.
575    #[test]
576    fn all_codes_unique_and_complete() {
577        use std::collections::HashSet;
578        let nums: HashSet<i32> = ErrorCode::ALL.iter().map(|c| c.code()).collect();
579        let strs: HashSet<&str> = ErrorCode::ALL.iter().map(|c| c.machine_code()).collect();
580        assert_eq!(nums.len(), ErrorCode::ALL.len(), "duplicate numeric code");
581        assert_eq!(strs.len(), ErrorCode::ALL.len(), "duplicate machine code");
582        assert_eq!(ErrorCode::ALL.len(), 30);
583    }
584
585    /// **Proves:** the constructor mints the full `{code, message, data:{code,
586    /// origin}}` envelope and `data.code` mirrors the numeric code.
587    /// **Catches:** a drift between the numeric code and `data.code`.
588    #[test]
589    fn envelope_shape_and_data_code() {
590        let e = RpcError::new(
591            ErrorCode::ResourceUnavailable,
592            "not here",
593            ErrorOrigin::Node,
594        );
595        let v = serde_json::to_value(&e).unwrap();
596        assert_eq!(v["code"], -32004);
597        assert_eq!(v["message"], "not here");
598        assert_eq!(v["data"]["code"], "RESOURCE_UNAVAILABLE");
599        assert_eq!(v["data"]["origin"], "node");
600        // Round-trips.
601        let back: RpcError = serde_json::from_value(v).unwrap();
602        assert_eq!(back, e);
603    }
604
605    /// **Proves:** `UpstreamError` defaults to the `upstream` origin.
606    #[test]
607    fn upstream_default_origin() {
608        let e = RpcError::of(ErrorCode::UpstreamError, "upstream: boom");
609        assert_eq!(e.data.origin, ErrorOrigin::Upstream);
610        assert_eq!(
611            serde_json::to_value(&e).unwrap()["data"]["origin"],
612            "upstream"
613        );
614    }
615
616    /// **Proves:** `code_only` uses the default message + origin.
617    #[test]
618    fn code_only_defaults() {
619        let e = RpcError::code_only(ErrorCode::PeerUnreachable);
620        assert_eq!(e.message, "Peer unreachable");
621        assert_eq!(e.data.origin, ErrorOrigin::Peer);
622    }
623}