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