Skip to main content

nodedb_types/error/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! [`NodeDbError`] struct + accessors + category predicates + `From<io::Error>`.
4
5use std::fmt;
6
7use serde::{Deserialize, Serialize};
8
9use super::code::ErrorCode;
10use super::details::ErrorDetails;
11
12/// Public error type returned by all `NodeDb` trait methods.
13///
14/// Separates machine-readable data ([`ErrorCode`] + [`ErrorDetails`]) from
15/// the human-readable `message`. Optional `cause` preserves the error chain.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct NodeDbError {
18    pub(super) code: ErrorCode,
19    pub(super) message: String,
20    pub(super) details: ErrorDetails,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub(super) cause: Option<Box<NodeDbError>>,
23}
24
25impl fmt::Display for NodeDbError {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        write!(f, "[{}] {}", self.code, self.message)
28    }
29}
30
31impl std::error::Error for NodeDbError {
32    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
33        self.cause.as_deref().map(|e| e as &dyn std::error::Error)
34    }
35}
36
37// ── Accessors ──
38
39impl NodeDbError {
40    /// The stable numeric error code.
41    pub fn code(&self) -> ErrorCode {
42        self.code
43    }
44
45    /// Human-readable error message.
46    pub fn message(&self) -> &str {
47        &self.message
48    }
49
50    /// Machine-matchable error details.
51    pub fn details(&self) -> &ErrorDetails {
52        &self.details
53    }
54
55    /// The chained cause, if any.
56    pub fn cause(&self) -> Option<&NodeDbError> {
57        self.cause.as_deref()
58    }
59
60    /// Attach a cause to this error.
61    pub fn with_cause(mut self, cause: NodeDbError) -> Self {
62        self.cause = Some(Box::new(cause));
63        self
64    }
65
66    /// Whether this error is retriable by the client.
67    pub fn is_retriable(&self) -> bool {
68        matches!(
69            self.details,
70            ErrorDetails::WriteConflict { .. }
71                | ErrorDetails::TransactionRollback { .. }
72                | ErrorDetails::DeadlineExceeded
73                | ErrorDetails::NoLeader
74                | ErrorDetails::NotLeader { .. }
75                | ErrorDetails::MigrationInProgress
76                | ErrorDetails::NodeUnreachable
77                | ErrorDetails::SyncConnectionFailed
78                | ErrorDetails::Bridge { .. }
79                | ErrorDetails::MemoryExhausted { .. }
80        )
81    }
82
83    /// Whether this error indicates the client sent invalid input.
84    pub fn is_client_error(&self) -> bool {
85        matches!(
86            self.details,
87            ErrorDetails::BadRequest
88                | ErrorDetails::ConstraintViolation { .. }
89                | ErrorDetails::AppendOnlyViolation { .. }
90                | ErrorDetails::BalanceViolation { .. }
91                | ErrorDetails::PeriodLocked { .. }
92                | ErrorDetails::StateTransitionViolation { .. }
93                | ErrorDetails::TransitionCheckViolation { .. }
94                | ErrorDetails::RetentionViolation { .. }
95                | ErrorDetails::LegalHoldActive { .. }
96                | ErrorDetails::CollectionNotFound { .. }
97                | ErrorDetails::DocumentNotFound { .. }
98                | ErrorDetails::AuthorizationDenied { .. }
99                | ErrorDetails::AuthExpired
100                | ErrorDetails::AuthenticationFailed
101                | ErrorDetails::Config
102                | ErrorDetails::SqlNotEnabled
103                | ErrorDetails::UndefinedFunction { .. }
104                | ErrorDetails::UndefinedColumn { .. }
105                | ErrorDetails::AmbiguousColumn { .. }
106                | ErrorDetails::DivisionByZero
107                | ErrorDetails::DataException { .. }
108                | ErrorDetails::ProgramLimitExceeded { .. }
109                | ErrorDetails::ActiveSqlTransaction { .. }
110                | ErrorDetails::DependentObjectsExist { .. }
111                | ErrorDetails::InvalidLimitValue { .. }
112                | ErrorDetails::BackupTenantMismatch { .. }
113                | ErrorDetails::BackupKeyMismatch
114        )
115    }
116}
117
118// ── Category predicates ──
119
120impl NodeDbError {
121    pub fn is_constraint_violation(&self) -> bool {
122        matches!(self.details, ErrorDetails::ConstraintViolation { .. })
123    }
124    pub fn is_not_found(&self) -> bool {
125        matches!(
126            self.details,
127            ErrorDetails::CollectionNotFound { .. }
128                | ErrorDetails::DocumentNotFound { .. }
129                | ErrorDetails::DatabaseNotFound { .. }
130        )
131    }
132    pub fn is_auth_denied(&self) -> bool {
133        matches!(self.details, ErrorDetails::AuthorizationDenied { .. })
134    }
135    pub fn is_storage(&self) -> bool {
136        matches!(
137            self.details,
138            ErrorDetails::Storage { .. }
139                | ErrorDetails::SegmentCorrupted { .. }
140                | ErrorDetails::ColdStorage { .. }
141                | ErrorDetails::Wal { .. }
142        )
143    }
144    pub fn is_internal(&self) -> bool {
145        matches!(self.details, ErrorDetails::Internal { .. })
146    }
147    pub fn is_type_mismatch(&self) -> bool {
148        matches!(self.details, ErrorDetails::TypeMismatch { .. })
149    }
150    pub fn is_type_guard_violation(&self) -> bool {
151        matches!(self.details, ErrorDetails::TypeGuardViolation { .. })
152    }
153    pub fn is_overflow(&self) -> bool {
154        matches!(self.details, ErrorDetails::Overflow { .. })
155    }
156    pub fn is_insufficient_balance(&self) -> bool {
157        matches!(self.details, ErrorDetails::InsufficientBalance { .. })
158    }
159    pub fn is_rate_exceeded(&self) -> bool {
160        matches!(self.details, ErrorDetails::RateExceeded { .. })
161    }
162    pub fn is_cluster(&self) -> bool {
163        matches!(
164            self.details,
165            ErrorDetails::NoLeader
166                | ErrorDetails::NotLeader { .. }
167                | ErrorDetails::MigrationInProgress
168                | ErrorDetails::NodeUnreachable
169                | ErrorDetails::Cluster
170        )
171    }
172}
173
174/// Result alias for NodeDb operations.
175pub type NodeDbResult<T> = std::result::Result<T, NodeDbError>;
176
177impl From<std::io::Error> for NodeDbError {
178    fn from(e: std::io::Error) -> Self {
179        Self::storage(e)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn error_display_includes_code() {
189        let e = NodeDbError::constraint_violation("users", "unique", "duplicate email");
190        let msg = e.to_string();
191        assert!(msg.contains("NDB-1000"));
192        assert!(msg.contains("constraint violation"));
193        assert!(msg.contains("users"));
194    }
195
196    #[test]
197    fn error_code_accessor() {
198        let e = NodeDbError::write_conflict("orders", "abc");
199        assert_eq!(e.code(), ErrorCode::WRITE_CONFLICT);
200        assert_eq!(e.code().0, 1001);
201    }
202
203    #[test]
204    fn details_matching() {
205        let e = NodeDbError::collection_not_found("users");
206        assert!(matches!(
207            e.details(),
208            ErrorDetails::CollectionNotFound { collection } if collection == "users"
209        ));
210        assert!(e.is_not_found());
211    }
212
213    #[test]
214    fn error_cause_chaining() {
215        let inner = NodeDbError::storage("disk full");
216        let outer = NodeDbError::internal("write failed").with_cause(inner);
217        assert!(outer.cause().is_some());
218        assert!(outer.cause().unwrap().is_storage());
219        assert!(outer.cause().unwrap().message().contains("disk full"));
220    }
221
222    #[test]
223    fn io_error_converts() {
224        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
225        let e: NodeDbError = io_err.into();
226        assert!(e.is_storage());
227        assert_eq!(e.code(), ErrorCode::STORAGE);
228    }
229
230    #[test]
231    fn retriable_errors() {
232        assert!(NodeDbError::write_conflict("x", "y").is_retriable());
233        assert!(NodeDbError::deadline_exceeded().is_retriable());
234        assert!(!NodeDbError::bad_request("bad").is_retriable());
235    }
236
237    #[test]
238    fn client_errors() {
239        assert!(NodeDbError::bad_request("bad").is_client_error());
240        assert!(!NodeDbError::internal("oops").is_client_error());
241    }
242
243    /// A participant rollback is retriable. A statement refused inside a
244    /// transaction block and a drop refused by dependents are client errors.
245    #[test]
246    fn transaction_and_dependency_codes_classify() {
247        let rollback = NodeDbError::transaction_rollback("participant failed");
248        assert!(rollback.is_retriable());
249        assert!(!rollback.is_client_error());
250        assert_eq!(rollback.code(), ErrorCode::TRANSACTION_ROLLBACK);
251
252        let in_block = NodeDbError::active_sql_transaction("VACUUM");
253        assert!(in_block.is_client_error());
254        assert!(!in_block.is_retriable());
255        assert_eq!(in_block.code(), ErrorCode::ACTIVE_SQL_TRANSACTION);
256
257        let dependents = NodeDbError::dependent_objects_exist("role \"r\"", "held by users");
258        assert!(dependents.is_client_error());
259        assert!(!dependents.is_retriable());
260        assert_eq!(dependents.code(), ErrorCode::DEPENDENT_OBJECTS_EXIST);
261    }
262
263    #[test]
264    fn json_serialization() {
265        let e = NodeDbError::collection_not_found("users");
266        let json = serde_json::to_value(&e).unwrap();
267        assert_eq!(json["code"], 1100);
268        assert!(json["message"].as_str().unwrap().contains("users"));
269        assert_eq!(json["details"]["kind"], "collection_not_found");
270        assert_eq!(json["details"]["collection"], "users");
271    }
272
273    #[test]
274    fn sync_delta_rejected_ctor() {
275        let e = NodeDbError::sync_delta_rejected(
276            "unique violation",
277            Some(
278                crate::sync::compensation::CompensationHint::UniqueViolation {
279                    field: "email".into(),
280                    conflicting_value: "a@b.com".into(),
281                },
282            ),
283        );
284        assert!(e.to_string().contains("NDB-3001"));
285        assert!(e.to_string().contains("sync delta rejected"));
286    }
287
288    #[test]
289    fn sql_not_enabled_ctor() {
290        let e = NodeDbError::sql_not_enabled();
291        assert!(e.to_string().contains("SQL not enabled"));
292        assert_eq!(e.code(), ErrorCode::SQL_NOT_ENABLED);
293    }
294}