Skip to main content

nodedb_types/
error.rs

1//! Standardized error types for the NodeDB public API.
2//!
3//! [`NodeDbError`] is a **struct** (not an enum) that separates:
4//! - `code` — stable numeric code for programmatic handling (`NDB-1000`)
5//! - `message` — human-readable explanation
6//! - `details` — machine-matchable [`ErrorDetails`] enum with structured data
7//! - `cause` — optional chained error for debugging
8//!
9//! # Wire format
10//!
11//! Serializes to:
12//! ```json
13//! {
14//!   "code": "NDB-1000",
15//!   "message": "constraint violation on users: duplicate email",
16//!   "details": { "kind": "constraint_violation", "collection": "users" }
17//! }
18//! ```
19//!
20//! # Error code ranges
21//!
22//! | Range       | Category      |
23//! |-------------|---------------|
24//! | 1000–1099   | Write path    |
25//! | 1100–1199   | Read path     |
26//! | 1200–1299   | Query         |
27//! | 2000–2099   | Auth/Security |
28//! | 3000–3099   | Sync          |
29//! | 4000–4099   | Storage       |
30//! | 4100–4199   | WAL           |
31//! | 4200–4299   | Serialization |
32//! | 5000–5099   | Config        |
33//! | 6000–6099   | Cluster       |
34//! | 7000–7099   | Memory        |
35//! | 8000–8099   | Encryption    |
36//! | 9000–9099   | Internal      |
37
38use std::fmt;
39
40use serde::{Deserialize, Serialize};
41
42// ---------------------------------------------------------------------------
43// Error codes
44// ---------------------------------------------------------------------------
45
46/// Stable numeric error codes for programmatic error handling.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub struct ErrorCode(pub u16);
49
50impl ErrorCode {
51    // Write path (1000–1099)
52    pub const CONSTRAINT_VIOLATION: Self = Self(1000);
53    pub const WRITE_CONFLICT: Self = Self(1001);
54    pub const DEADLINE_EXCEEDED: Self = Self(1002);
55    pub const PREVALIDATION_REJECTED: Self = Self(1003);
56
57    // Read path (1100–1199)
58    pub const COLLECTION_NOT_FOUND: Self = Self(1100);
59    pub const DOCUMENT_NOT_FOUND: Self = Self(1101);
60
61    // Query (1200–1299)
62    pub const PLAN_ERROR: Self = Self(1200);
63    pub const FAN_OUT_EXCEEDED: Self = Self(1201);
64    pub const SQL_NOT_ENABLED: Self = Self(1202);
65
66    // Auth / Security (2000–2099)
67    pub const AUTHORIZATION_DENIED: Self = Self(2000);
68    pub const AUTH_EXPIRED: Self = Self(2001);
69
70    // Sync (3000–3099)
71    pub const SYNC_CONNECTION_FAILED: Self = Self(3000);
72    pub const SYNC_DELTA_REJECTED: Self = Self(3001);
73    pub const SHAPE_SUBSCRIPTION_FAILED: Self = Self(3002);
74
75    // Storage (4000–4099)
76    pub const STORAGE: Self = Self(4000);
77    pub const SEGMENT_CORRUPTED: Self = Self(4001);
78    pub const COLD_STORAGE: Self = Self(4002);
79
80    // WAL (4100–4199)
81    pub const WAL: Self = Self(4100);
82
83    // Serialization (4200–4299)
84    pub const SERIALIZATION: Self = Self(4200);
85    pub const CODEC: Self = Self(4201);
86
87    // Config (5000–5099)
88    pub const CONFIG: Self = Self(5000);
89    pub const BAD_REQUEST: Self = Self(5001);
90
91    // Cluster (6000–6099)
92    pub const NO_LEADER: Self = Self(6000);
93    pub const NOT_LEADER: Self = Self(6001);
94    pub const MIGRATION_IN_PROGRESS: Self = Self(6002);
95    pub const NODE_UNREACHABLE: Self = Self(6003);
96    pub const CLUSTER: Self = Self(6010);
97
98    // Memory (7000–7099)
99    pub const MEMORY_EXHAUSTED: Self = Self(7000);
100
101    // Encryption (8000–8099)
102    pub const ENCRYPTION: Self = Self(8000);
103
104    // Internal (9000–9099)
105    pub const INTERNAL: Self = Self(9000);
106    pub const BRIDGE: Self = Self(9001);
107    pub const DISPATCH: Self = Self(9002);
108}
109
110impl fmt::Display for ErrorCode {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "NDB-{:04}", self.0)
113    }
114}
115
116// ---------------------------------------------------------------------------
117// ErrorDetails — machine-matchable structured data
118// ---------------------------------------------------------------------------
119
120/// Structured error details for programmatic matching.
121///
122/// Clients match on the variant to determine the error category, then
123/// extract structured fields. The `message` on [`NodeDbError`] carries
124/// the human-readable explanation.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(tag = "kind", rename_all = "snake_case")]
127pub enum ErrorDetails {
128    // Write path
129    ConstraintViolation {
130        collection: String,
131    },
132    WriteConflict {
133        collection: String,
134        document_id: String,
135    },
136    DeadlineExceeded,
137    PrevalidationRejected {
138        constraint: String,
139    },
140
141    // Read path
142    CollectionNotFound {
143        collection: String,
144    },
145    DocumentNotFound {
146        collection: String,
147        document_id: String,
148    },
149
150    // Query
151    PlanError,
152    FanOutExceeded {
153        shards_touched: u16,
154        limit: u16,
155    },
156    SqlNotEnabled,
157
158    // Auth
159    AuthorizationDenied {
160        resource: String,
161    },
162    AuthExpired,
163
164    // Sync
165    SyncConnectionFailed,
166    SyncDeltaRejected {
167        compensation: Option<crate::sync::compensation::CompensationHint>,
168    },
169    ShapeSubscriptionFailed {
170        shape_id: String,
171    },
172
173    // Storage (opaque infrastructure)
174    Storage,
175    SegmentCorrupted,
176    ColdStorage,
177    Wal,
178
179    // Serialization
180    Serialization {
181        format: String,
182    },
183    Codec,
184
185    // Config
186    Config,
187    BadRequest,
188
189    // Cluster
190    NoLeader,
191    NotLeader {
192        leader_addr: String,
193    },
194    MigrationInProgress,
195    NodeUnreachable,
196    Cluster,
197
198    // Memory
199    MemoryExhausted {
200        engine: String,
201    },
202
203    // Encryption
204    Encryption,
205
206    // Bridge / Dispatch / Internal
207    Bridge,
208    Dispatch,
209    Internal,
210}
211
212// ---------------------------------------------------------------------------
213// NodeDbError — the public error struct
214// ---------------------------------------------------------------------------
215
216/// Public error type returned by all `NodeDb` trait methods.
217///
218/// Separates machine-readable data ([`ErrorCode`] + [`ErrorDetails`]) from
219/// the human-readable `message`. Optional `cause` preserves the error chain.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct NodeDbError {
222    code: ErrorCode,
223    message: String,
224    details: ErrorDetails,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    cause: Option<Box<NodeDbError>>,
227}
228
229impl fmt::Display for NodeDbError {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        write!(f, "[{}] {}", self.code, self.message)
232    }
233}
234
235impl std::error::Error for NodeDbError {
236    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
237        self.cause.as_deref().map(|e| e as &dyn std::error::Error)
238    }
239}
240
241// ---------------------------------------------------------------------------
242// Accessors
243// ---------------------------------------------------------------------------
244
245impl NodeDbError {
246    /// The stable numeric error code.
247    pub fn code(&self) -> ErrorCode {
248        self.code
249    }
250
251    /// Human-readable error message.
252    pub fn message(&self) -> &str {
253        &self.message
254    }
255
256    /// Machine-matchable error details.
257    pub fn details(&self) -> &ErrorDetails {
258        &self.details
259    }
260
261    /// The chained cause, if any.
262    pub fn cause(&self) -> Option<&NodeDbError> {
263        self.cause.as_deref()
264    }
265
266    /// Attach a cause to this error.
267    pub fn with_cause(mut self, cause: NodeDbError) -> Self {
268        self.cause = Some(Box::new(cause));
269        self
270    }
271
272    /// Whether this error is retriable by the client.
273    pub fn is_retriable(&self) -> bool {
274        matches!(
275            self.details,
276            ErrorDetails::WriteConflict { .. }
277                | ErrorDetails::DeadlineExceeded
278                | ErrorDetails::NoLeader
279                | ErrorDetails::NotLeader { .. }
280                | ErrorDetails::MigrationInProgress
281                | ErrorDetails::NodeUnreachable
282                | ErrorDetails::SyncConnectionFailed
283                | ErrorDetails::Bridge
284                | ErrorDetails::MemoryExhausted { .. }
285        )
286    }
287
288    /// Whether this error indicates the client sent invalid input.
289    pub fn is_client_error(&self) -> bool {
290        matches!(
291            self.details,
292            ErrorDetails::BadRequest
293                | ErrorDetails::ConstraintViolation { .. }
294                | ErrorDetails::CollectionNotFound { .. }
295                | ErrorDetails::DocumentNotFound { .. }
296                | ErrorDetails::AuthorizationDenied { .. }
297                | ErrorDetails::AuthExpired
298                | ErrorDetails::Config
299                | ErrorDetails::SqlNotEnabled
300        )
301    }
302}
303
304// ---------------------------------------------------------------------------
305// Category checks
306// ---------------------------------------------------------------------------
307
308impl NodeDbError {
309    pub fn is_constraint_violation(&self) -> bool {
310        matches!(self.details, ErrorDetails::ConstraintViolation { .. })
311    }
312    pub fn is_not_found(&self) -> bool {
313        matches!(
314            self.details,
315            ErrorDetails::CollectionNotFound { .. } | ErrorDetails::DocumentNotFound { .. }
316        )
317    }
318    pub fn is_auth_denied(&self) -> bool {
319        matches!(self.details, ErrorDetails::AuthorizationDenied { .. })
320    }
321    pub fn is_storage(&self) -> bool {
322        matches!(
323            self.details,
324            ErrorDetails::Storage
325                | ErrorDetails::SegmentCorrupted
326                | ErrorDetails::ColdStorage
327                | ErrorDetails::Wal
328        )
329    }
330    pub fn is_internal(&self) -> bool {
331        matches!(self.details, ErrorDetails::Internal)
332    }
333    pub fn is_cluster(&self) -> bool {
334        matches!(
335            self.details,
336            ErrorDetails::NoLeader
337                | ErrorDetails::NotLeader { .. }
338                | ErrorDetails::MigrationInProgress
339                | ErrorDetails::NodeUnreachable
340                | ErrorDetails::Cluster
341        )
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Constructors
347// ---------------------------------------------------------------------------
348
349impl NodeDbError {
350    // ── Write path ──
351
352    pub fn constraint_violation(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
353        let collection = collection.into();
354        Self {
355            code: ErrorCode::CONSTRAINT_VIOLATION,
356            message: format!("constraint violation on {collection}: {detail}"),
357            details: ErrorDetails::ConstraintViolation { collection },
358            cause: None,
359        }
360    }
361
362    pub fn write_conflict(collection: impl Into<String>, document_id: impl Into<String>) -> Self {
363        let collection = collection.into();
364        let document_id = document_id.into();
365        Self {
366            code: ErrorCode::WRITE_CONFLICT,
367            message: format!(
368                "write conflict on {collection}/{document_id}, retry with idempotency key"
369            ),
370            details: ErrorDetails::WriteConflict {
371                collection,
372                document_id,
373            },
374            cause: None,
375        }
376    }
377
378    pub fn deadline_exceeded() -> Self {
379        Self {
380            code: ErrorCode::DEADLINE_EXCEEDED,
381            message: "request exceeded deadline".into(),
382            details: ErrorDetails::DeadlineExceeded,
383            cause: None,
384        }
385    }
386
387    pub fn prevalidation_rejected(
388        constraint: impl Into<String>,
389        reason: impl fmt::Display,
390    ) -> Self {
391        let constraint = constraint.into();
392        Self {
393            code: ErrorCode::PREVALIDATION_REJECTED,
394            message: format!("pre-validation rejected: {constraint} — {reason}"),
395            details: ErrorDetails::PrevalidationRejected { constraint },
396            cause: None,
397        }
398    }
399
400    // ── Read path ──
401
402    pub fn collection_not_found(collection: impl Into<String>) -> Self {
403        let collection = collection.into();
404        Self {
405            code: ErrorCode::COLLECTION_NOT_FOUND,
406            message: format!("collection '{collection}' not found"),
407            details: ErrorDetails::CollectionNotFound { collection },
408            cause: None,
409        }
410    }
411
412    pub fn document_not_found(
413        collection: impl Into<String>,
414        document_id: impl Into<String>,
415    ) -> Self {
416        let collection = collection.into();
417        let document_id = document_id.into();
418        Self {
419            code: ErrorCode::DOCUMENT_NOT_FOUND,
420            message: format!("document '{document_id}' not found in '{collection}'"),
421            details: ErrorDetails::DocumentNotFound {
422                collection,
423                document_id,
424            },
425            cause: None,
426        }
427    }
428
429    // ── Query ──
430
431    pub fn plan_error(detail: impl fmt::Display) -> Self {
432        Self {
433            code: ErrorCode::PLAN_ERROR,
434            message: format!("query plan error: {detail}"),
435            details: ErrorDetails::PlanError,
436            cause: None,
437        }
438    }
439
440    pub fn fan_out_exceeded(shards_touched: u16, limit: u16) -> Self {
441        Self {
442            code: ErrorCode::FAN_OUT_EXCEEDED,
443            message: format!("query fan-out exceeded: {shards_touched} shards > limit {limit}"),
444            details: ErrorDetails::FanOutExceeded {
445                shards_touched,
446                limit,
447            },
448            cause: None,
449        }
450    }
451
452    pub fn sql_not_enabled() -> Self {
453        Self {
454            code: ErrorCode::SQL_NOT_ENABLED,
455            message: "SQL not enabled (compile with 'sql' feature)".into(),
456            details: ErrorDetails::SqlNotEnabled,
457            cause: None,
458        }
459    }
460
461    // ── Auth ──
462
463    pub fn authorization_denied(resource: impl Into<String>) -> Self {
464        let resource = resource.into();
465        Self {
466            code: ErrorCode::AUTHORIZATION_DENIED,
467            message: format!("authorization denied on {resource}"),
468            details: ErrorDetails::AuthorizationDenied { resource },
469            cause: None,
470        }
471    }
472
473    pub fn auth_expired(detail: impl fmt::Display) -> Self {
474        Self {
475            code: ErrorCode::AUTH_EXPIRED,
476            message: format!("auth expired: {detail}"),
477            details: ErrorDetails::AuthExpired,
478            cause: None,
479        }
480    }
481
482    // ── Sync ──
483
484    pub fn sync_connection_failed(detail: impl fmt::Display) -> Self {
485        Self {
486            code: ErrorCode::SYNC_CONNECTION_FAILED,
487            message: format!("sync connection failed: {detail}"),
488            details: ErrorDetails::SyncConnectionFailed,
489            cause: None,
490        }
491    }
492
493    pub fn sync_delta_rejected(
494        reason: impl fmt::Display,
495        compensation: Option<crate::sync::compensation::CompensationHint>,
496    ) -> Self {
497        Self {
498            code: ErrorCode::SYNC_DELTA_REJECTED,
499            message: format!("sync delta rejected: {reason}"),
500            details: ErrorDetails::SyncDeltaRejected { compensation },
501            cause: None,
502        }
503    }
504
505    pub fn shape_subscription_failed(
506        shape_id: impl Into<String>,
507        detail: impl fmt::Display,
508    ) -> Self {
509        let shape_id = shape_id.into();
510        Self {
511            code: ErrorCode::SHAPE_SUBSCRIPTION_FAILED,
512            message: format!("shape subscription failed for '{shape_id}': {detail}"),
513            details: ErrorDetails::ShapeSubscriptionFailed { shape_id },
514            cause: None,
515        }
516    }
517
518    // ── Storage ──
519
520    pub fn storage(detail: impl fmt::Display) -> Self {
521        Self {
522            code: ErrorCode::STORAGE,
523            message: format!("storage error: {detail}"),
524            details: ErrorDetails::Storage,
525            cause: None,
526        }
527    }
528
529    pub fn segment_corrupted(detail: impl fmt::Display) -> Self {
530        Self {
531            code: ErrorCode::SEGMENT_CORRUPTED,
532            message: format!("segment corrupted: {detail}"),
533            details: ErrorDetails::SegmentCorrupted,
534            cause: None,
535        }
536    }
537
538    pub fn cold_storage(detail: impl fmt::Display) -> Self {
539        Self {
540            code: ErrorCode::COLD_STORAGE,
541            message: format!("cold storage error: {detail}"),
542            details: ErrorDetails::ColdStorage,
543            cause: None,
544        }
545    }
546
547    pub fn wal(detail: impl fmt::Display) -> Self {
548        Self {
549            code: ErrorCode::WAL,
550            message: format!("WAL error: {detail}"),
551            details: ErrorDetails::Wal,
552            cause: None,
553        }
554    }
555
556    // ── Serialization ──
557
558    pub fn serialization(format: impl Into<String>, detail: impl fmt::Display) -> Self {
559        let format = format.into();
560        Self {
561            code: ErrorCode::SERIALIZATION,
562            message: format!("serialization error ({format}): {detail}"),
563            details: ErrorDetails::Serialization { format },
564            cause: None,
565        }
566    }
567
568    pub fn codec(detail: impl fmt::Display) -> Self {
569        Self {
570            code: ErrorCode::CODEC,
571            message: format!("codec error: {detail}"),
572            details: ErrorDetails::Codec,
573            cause: None,
574        }
575    }
576
577    // ── Config ──
578
579    pub fn config(detail: impl fmt::Display) -> Self {
580        Self {
581            code: ErrorCode::CONFIG,
582            message: format!("configuration error: {detail}"),
583            details: ErrorDetails::Config,
584            cause: None,
585        }
586    }
587
588    pub fn bad_request(detail: impl fmt::Display) -> Self {
589        Self {
590            code: ErrorCode::BAD_REQUEST,
591            message: format!("bad request: {detail}"),
592            details: ErrorDetails::BadRequest,
593            cause: None,
594        }
595    }
596
597    // ── Cluster ──
598
599    pub fn no_leader(detail: impl fmt::Display) -> Self {
600        Self {
601            code: ErrorCode::NO_LEADER,
602            message: format!("no serving leader: {detail}"),
603            details: ErrorDetails::NoLeader,
604            cause: None,
605        }
606    }
607
608    pub fn not_leader(leader_addr: impl Into<String>) -> Self {
609        let leader_addr = leader_addr.into();
610        Self {
611            code: ErrorCode::NOT_LEADER,
612            message: format!("not leader; redirect to leader at {leader_addr}"),
613            details: ErrorDetails::NotLeader { leader_addr },
614            cause: None,
615        }
616    }
617
618    pub fn migration_in_progress(detail: impl fmt::Display) -> Self {
619        Self {
620            code: ErrorCode::MIGRATION_IN_PROGRESS,
621            message: format!("migration in progress: {detail}"),
622            details: ErrorDetails::MigrationInProgress,
623            cause: None,
624        }
625    }
626
627    pub fn node_unreachable(detail: impl fmt::Display) -> Self {
628        Self {
629            code: ErrorCode::NODE_UNREACHABLE,
630            message: format!("node unreachable: {detail}"),
631            details: ErrorDetails::NodeUnreachable,
632            cause: None,
633        }
634    }
635
636    pub fn cluster(detail: impl fmt::Display) -> Self {
637        Self {
638            code: ErrorCode::CLUSTER,
639            message: format!("cluster error: {detail}"),
640            details: ErrorDetails::Cluster,
641            cause: None,
642        }
643    }
644
645    // ── Memory ──
646
647    pub fn memory_exhausted(engine: impl Into<String>) -> Self {
648        let engine = engine.into();
649        Self {
650            code: ErrorCode::MEMORY_EXHAUSTED,
651            message: format!("memory budget exhausted for engine {engine}"),
652            details: ErrorDetails::MemoryExhausted { engine },
653            cause: None,
654        }
655    }
656
657    // ── Encryption ──
658
659    pub fn encryption(detail: impl fmt::Display) -> Self {
660        Self {
661            code: ErrorCode::ENCRYPTION,
662            message: format!("encryption error: {detail}"),
663            details: ErrorDetails::Encryption,
664            cause: None,
665        }
666    }
667
668    // ── Bridge / Dispatch / Internal ──
669
670    pub fn bridge(detail: impl fmt::Display) -> Self {
671        Self {
672            code: ErrorCode::BRIDGE,
673            message: format!("bridge error: {detail}"),
674            details: ErrorDetails::Bridge,
675            cause: None,
676        }
677    }
678
679    pub fn dispatch(detail: impl fmt::Display) -> Self {
680        Self {
681            code: ErrorCode::DISPATCH,
682            message: format!("dispatch error: {detail}"),
683            details: ErrorDetails::Dispatch,
684            cause: None,
685        }
686    }
687
688    pub fn internal(detail: impl fmt::Display) -> Self {
689        Self {
690            code: ErrorCode::INTERNAL,
691            message: format!("internal error: {detail}"),
692            details: ErrorDetails::Internal,
693            cause: None,
694        }
695    }
696}
697
698/// Result alias for NodeDb operations.
699pub type NodeDbResult<T> = std::result::Result<T, NodeDbError>;
700
701impl From<std::io::Error> for NodeDbError {
702    fn from(e: std::io::Error) -> Self {
703        Self::storage(e)
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn error_code_display() {
713        assert_eq!(ErrorCode::CONSTRAINT_VIOLATION.to_string(), "NDB-1000");
714        assert_eq!(ErrorCode::INTERNAL.to_string(), "NDB-9000");
715        assert_eq!(ErrorCode::WAL.to_string(), "NDB-4100");
716    }
717
718    #[test]
719    fn error_display_includes_code() {
720        let e = NodeDbError::constraint_violation("users", "duplicate email");
721        let msg = e.to_string();
722        assert!(msg.contains("NDB-1000"));
723        assert!(msg.contains("constraint violation"));
724        assert!(msg.contains("users"));
725    }
726
727    #[test]
728    fn error_code_accessor() {
729        let e = NodeDbError::write_conflict("orders", "abc");
730        assert_eq!(e.code(), ErrorCode::WRITE_CONFLICT);
731        assert_eq!(e.code().0, 1001);
732    }
733
734    #[test]
735    fn details_matching() {
736        let e = NodeDbError::collection_not_found("users");
737        assert!(matches!(
738            e.details(),
739            ErrorDetails::CollectionNotFound { collection } if collection == "users"
740        ));
741        assert!(e.is_not_found());
742    }
743
744    #[test]
745    fn error_cause_chaining() {
746        let inner = NodeDbError::storage("disk full");
747        let outer = NodeDbError::internal("write failed").with_cause(inner);
748        assert!(outer.cause().is_some());
749        assert!(outer.cause().unwrap().is_storage());
750        assert!(outer.cause().unwrap().message().contains("disk full"));
751    }
752
753    #[test]
754    fn sync_delta_rejected() {
755        let e = NodeDbError::sync_delta_rejected(
756            "unique violation",
757            Some(
758                crate::sync::compensation::CompensationHint::UniqueViolation {
759                    field: "email".into(),
760                    conflicting_value: "a@b.com".into(),
761                },
762            ),
763        );
764        assert!(e.to_string().contains("NDB-3001"));
765        assert!(e.to_string().contains("sync delta rejected"));
766    }
767
768    #[test]
769    fn sql_not_enabled() {
770        let e = NodeDbError::sql_not_enabled();
771        assert!(e.to_string().contains("SQL not enabled"));
772        assert_eq!(e.code(), ErrorCode::SQL_NOT_ENABLED);
773    }
774
775    #[test]
776    fn io_error_converts() {
777        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
778        let e: NodeDbError = io_err.into();
779        assert!(e.is_storage());
780        assert_eq!(e.code(), ErrorCode::STORAGE);
781    }
782
783    #[test]
784    fn retriable_errors() {
785        assert!(NodeDbError::write_conflict("x", "y").is_retriable());
786        assert!(NodeDbError::deadline_exceeded().is_retriable());
787        assert!(!NodeDbError::bad_request("bad").is_retriable());
788    }
789
790    #[test]
791    fn client_errors() {
792        assert!(NodeDbError::bad_request("bad").is_client_error());
793        assert!(!NodeDbError::internal("oops").is_client_error());
794    }
795
796    #[test]
797    fn json_serialization() {
798        let e = NodeDbError::collection_not_found("users");
799        let json = serde_json::to_value(&e).unwrap();
800        assert_eq!(json["code"], 1100);
801        assert!(json["message"].as_str().unwrap().contains("users"));
802        assert_eq!(json["details"]["kind"], "collection_not_found");
803        assert_eq!(json["details"]["collection"], "users");
804    }
805}