use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ErrorCode(pub u16);
impl ErrorCode {
pub const CONSTRAINT_VIOLATION: Self = Self(1000);
pub const WRITE_CONFLICT: Self = Self(1001);
pub const DEADLINE_EXCEEDED: Self = Self(1002);
pub const PREVALIDATION_REJECTED: Self = Self(1003);
pub const COLLECTION_NOT_FOUND: Self = Self(1100);
pub const DOCUMENT_NOT_FOUND: Self = Self(1101);
pub const PLAN_ERROR: Self = Self(1200);
pub const FAN_OUT_EXCEEDED: Self = Self(1201);
pub const SQL_NOT_ENABLED: Self = Self(1202);
pub const AUTHORIZATION_DENIED: Self = Self(2000);
pub const AUTH_EXPIRED: Self = Self(2001);
pub const SYNC_CONNECTION_FAILED: Self = Self(3000);
pub const SYNC_DELTA_REJECTED: Self = Self(3001);
pub const SHAPE_SUBSCRIPTION_FAILED: Self = Self(3002);
pub const STORAGE: Self = Self(4000);
pub const SEGMENT_CORRUPTED: Self = Self(4001);
pub const COLD_STORAGE: Self = Self(4002);
pub const WAL: Self = Self(4100);
pub const SERIALIZATION: Self = Self(4200);
pub const CODEC: Self = Self(4201);
pub const CONFIG: Self = Self(5000);
pub const BAD_REQUEST: Self = Self(5001);
pub const NO_LEADER: Self = Self(6000);
pub const NOT_LEADER: Self = Self(6001);
pub const MIGRATION_IN_PROGRESS: Self = Self(6002);
pub const NODE_UNREACHABLE: Self = Self(6003);
pub const CLUSTER: Self = Self(6010);
pub const MEMORY_EXHAUSTED: Self = Self(7000);
pub const ENCRYPTION: Self = Self(8000);
pub const INTERNAL: Self = Self(9000);
pub const BRIDGE: Self = Self(9001);
pub const DISPATCH: Self = Self(9002);
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "NDB-{:04}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ErrorDetails {
ConstraintViolation {
collection: String,
},
WriteConflict {
collection: String,
document_id: String,
},
DeadlineExceeded,
PrevalidationRejected {
constraint: String,
},
CollectionNotFound {
collection: String,
},
DocumentNotFound {
collection: String,
document_id: String,
},
PlanError,
FanOutExceeded {
shards_touched: u16,
limit: u16,
},
SqlNotEnabled,
AuthorizationDenied {
resource: String,
},
AuthExpired,
SyncConnectionFailed,
SyncDeltaRejected {
compensation: Option<crate::sync::compensation::CompensationHint>,
},
ShapeSubscriptionFailed {
shape_id: String,
},
Storage,
SegmentCorrupted,
ColdStorage,
Wal,
Serialization {
format: String,
},
Codec,
Config,
BadRequest,
NoLeader,
NotLeader {
leader_addr: String,
},
MigrationInProgress,
NodeUnreachable,
Cluster,
MemoryExhausted {
engine: String,
},
Encryption,
Bridge,
Dispatch,
Internal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeDbError {
code: ErrorCode,
message: String,
details: ErrorDetails,
#[serde(skip_serializing_if = "Option::is_none")]
cause: Option<Box<NodeDbError>>,
}
impl fmt::Display for NodeDbError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}", self.code, self.message)
}
}
impl std::error::Error for NodeDbError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.cause.as_deref().map(|e| e as &dyn std::error::Error)
}
}
impl NodeDbError {
pub fn code(&self) -> ErrorCode {
self.code
}
pub fn message(&self) -> &str {
&self.message
}
pub fn details(&self) -> &ErrorDetails {
&self.details
}
pub fn cause(&self) -> Option<&NodeDbError> {
self.cause.as_deref()
}
pub fn with_cause(mut self, cause: NodeDbError) -> Self {
self.cause = Some(Box::new(cause));
self
}
pub fn is_retriable(&self) -> bool {
matches!(
self.details,
ErrorDetails::WriteConflict { .. }
| ErrorDetails::DeadlineExceeded
| ErrorDetails::NoLeader
| ErrorDetails::NotLeader { .. }
| ErrorDetails::MigrationInProgress
| ErrorDetails::NodeUnreachable
| ErrorDetails::SyncConnectionFailed
| ErrorDetails::Bridge
| ErrorDetails::MemoryExhausted { .. }
)
}
pub fn is_client_error(&self) -> bool {
matches!(
self.details,
ErrorDetails::BadRequest
| ErrorDetails::ConstraintViolation { .. }
| ErrorDetails::CollectionNotFound { .. }
| ErrorDetails::DocumentNotFound { .. }
| ErrorDetails::AuthorizationDenied { .. }
| ErrorDetails::AuthExpired
| ErrorDetails::Config
| ErrorDetails::SqlNotEnabled
)
}
}
impl NodeDbError {
pub fn is_constraint_violation(&self) -> bool {
matches!(self.details, ErrorDetails::ConstraintViolation { .. })
}
pub fn is_not_found(&self) -> bool {
matches!(
self.details,
ErrorDetails::CollectionNotFound { .. } | ErrorDetails::DocumentNotFound { .. }
)
}
pub fn is_auth_denied(&self) -> bool {
matches!(self.details, ErrorDetails::AuthorizationDenied { .. })
}
pub fn is_storage(&self) -> bool {
matches!(
self.details,
ErrorDetails::Storage
| ErrorDetails::SegmentCorrupted
| ErrorDetails::ColdStorage
| ErrorDetails::Wal
)
}
pub fn is_internal(&self) -> bool {
matches!(self.details, ErrorDetails::Internal)
}
pub fn is_cluster(&self) -> bool {
matches!(
self.details,
ErrorDetails::NoLeader
| ErrorDetails::NotLeader { .. }
| ErrorDetails::MigrationInProgress
| ErrorDetails::NodeUnreachable
| ErrorDetails::Cluster
)
}
}
impl NodeDbError {
pub fn constraint_violation(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
let collection = collection.into();
Self {
code: ErrorCode::CONSTRAINT_VIOLATION,
message: format!("constraint violation on {collection}: {detail}"),
details: ErrorDetails::ConstraintViolation { collection },
cause: None,
}
}
pub fn write_conflict(collection: impl Into<String>, document_id: impl Into<String>) -> Self {
let collection = collection.into();
let document_id = document_id.into();
Self {
code: ErrorCode::WRITE_CONFLICT,
message: format!(
"write conflict on {collection}/{document_id}, retry with idempotency key"
),
details: ErrorDetails::WriteConflict {
collection,
document_id,
},
cause: None,
}
}
pub fn deadline_exceeded() -> Self {
Self {
code: ErrorCode::DEADLINE_EXCEEDED,
message: "request exceeded deadline".into(),
details: ErrorDetails::DeadlineExceeded,
cause: None,
}
}
pub fn prevalidation_rejected(
constraint: impl Into<String>,
reason: impl fmt::Display,
) -> Self {
let constraint = constraint.into();
Self {
code: ErrorCode::PREVALIDATION_REJECTED,
message: format!("pre-validation rejected: {constraint} — {reason}"),
details: ErrorDetails::PrevalidationRejected { constraint },
cause: None,
}
}
pub fn collection_not_found(collection: impl Into<String>) -> Self {
let collection = collection.into();
Self {
code: ErrorCode::COLLECTION_NOT_FOUND,
message: format!("collection '{collection}' not found"),
details: ErrorDetails::CollectionNotFound { collection },
cause: None,
}
}
pub fn document_not_found(
collection: impl Into<String>,
document_id: impl Into<String>,
) -> Self {
let collection = collection.into();
let document_id = document_id.into();
Self {
code: ErrorCode::DOCUMENT_NOT_FOUND,
message: format!("document '{document_id}' not found in '{collection}'"),
details: ErrorDetails::DocumentNotFound {
collection,
document_id,
},
cause: None,
}
}
pub fn plan_error(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::PLAN_ERROR,
message: format!("query plan error: {detail}"),
details: ErrorDetails::PlanError,
cause: None,
}
}
pub fn fan_out_exceeded(shards_touched: u16, limit: u16) -> Self {
Self {
code: ErrorCode::FAN_OUT_EXCEEDED,
message: format!("query fan-out exceeded: {shards_touched} shards > limit {limit}"),
details: ErrorDetails::FanOutExceeded {
shards_touched,
limit,
},
cause: None,
}
}
pub fn sql_not_enabled() -> Self {
Self {
code: ErrorCode::SQL_NOT_ENABLED,
message: "SQL not enabled (compile with 'sql' feature)".into(),
details: ErrorDetails::SqlNotEnabled,
cause: None,
}
}
pub fn authorization_denied(resource: impl Into<String>) -> Self {
let resource = resource.into();
Self {
code: ErrorCode::AUTHORIZATION_DENIED,
message: format!("authorization denied on {resource}"),
details: ErrorDetails::AuthorizationDenied { resource },
cause: None,
}
}
pub fn auth_expired(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::AUTH_EXPIRED,
message: format!("auth expired: {detail}"),
details: ErrorDetails::AuthExpired,
cause: None,
}
}
pub fn sync_connection_failed(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::SYNC_CONNECTION_FAILED,
message: format!("sync connection failed: {detail}"),
details: ErrorDetails::SyncConnectionFailed,
cause: None,
}
}
pub fn sync_delta_rejected(
reason: impl fmt::Display,
compensation: Option<crate::sync::compensation::CompensationHint>,
) -> Self {
Self {
code: ErrorCode::SYNC_DELTA_REJECTED,
message: format!("sync delta rejected: {reason}"),
details: ErrorDetails::SyncDeltaRejected { compensation },
cause: None,
}
}
pub fn shape_subscription_failed(
shape_id: impl Into<String>,
detail: impl fmt::Display,
) -> Self {
let shape_id = shape_id.into();
Self {
code: ErrorCode::SHAPE_SUBSCRIPTION_FAILED,
message: format!("shape subscription failed for '{shape_id}': {detail}"),
details: ErrorDetails::ShapeSubscriptionFailed { shape_id },
cause: None,
}
}
pub fn storage(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::STORAGE,
message: format!("storage error: {detail}"),
details: ErrorDetails::Storage,
cause: None,
}
}
pub fn segment_corrupted(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::SEGMENT_CORRUPTED,
message: format!("segment corrupted: {detail}"),
details: ErrorDetails::SegmentCorrupted,
cause: None,
}
}
pub fn cold_storage(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::COLD_STORAGE,
message: format!("cold storage error: {detail}"),
details: ErrorDetails::ColdStorage,
cause: None,
}
}
pub fn wal(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::WAL,
message: format!("WAL error: {detail}"),
details: ErrorDetails::Wal,
cause: None,
}
}
pub fn serialization(format: impl Into<String>, detail: impl fmt::Display) -> Self {
let format = format.into();
Self {
code: ErrorCode::SERIALIZATION,
message: format!("serialization error ({format}): {detail}"),
details: ErrorDetails::Serialization { format },
cause: None,
}
}
pub fn codec(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::CODEC,
message: format!("codec error: {detail}"),
details: ErrorDetails::Codec,
cause: None,
}
}
pub fn config(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::CONFIG,
message: format!("configuration error: {detail}"),
details: ErrorDetails::Config,
cause: None,
}
}
pub fn bad_request(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::BAD_REQUEST,
message: format!("bad request: {detail}"),
details: ErrorDetails::BadRequest,
cause: None,
}
}
pub fn no_leader(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::NO_LEADER,
message: format!("no serving leader: {detail}"),
details: ErrorDetails::NoLeader,
cause: None,
}
}
pub fn not_leader(leader_addr: impl Into<String>) -> Self {
let leader_addr = leader_addr.into();
Self {
code: ErrorCode::NOT_LEADER,
message: format!("not leader; redirect to leader at {leader_addr}"),
details: ErrorDetails::NotLeader { leader_addr },
cause: None,
}
}
pub fn migration_in_progress(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::MIGRATION_IN_PROGRESS,
message: format!("migration in progress: {detail}"),
details: ErrorDetails::MigrationInProgress,
cause: None,
}
}
pub fn node_unreachable(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::NODE_UNREACHABLE,
message: format!("node unreachable: {detail}"),
details: ErrorDetails::NodeUnreachable,
cause: None,
}
}
pub fn cluster(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::CLUSTER,
message: format!("cluster error: {detail}"),
details: ErrorDetails::Cluster,
cause: None,
}
}
pub fn memory_exhausted(engine: impl Into<String>) -> Self {
let engine = engine.into();
Self {
code: ErrorCode::MEMORY_EXHAUSTED,
message: format!("memory budget exhausted for engine {engine}"),
details: ErrorDetails::MemoryExhausted { engine },
cause: None,
}
}
pub fn encryption(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::ENCRYPTION,
message: format!("encryption error: {detail}"),
details: ErrorDetails::Encryption,
cause: None,
}
}
pub fn bridge(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::BRIDGE,
message: format!("bridge error: {detail}"),
details: ErrorDetails::Bridge,
cause: None,
}
}
pub fn dispatch(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::DISPATCH,
message: format!("dispatch error: {detail}"),
details: ErrorDetails::Dispatch,
cause: None,
}
}
pub fn internal(detail: impl fmt::Display) -> Self {
Self {
code: ErrorCode::INTERNAL,
message: format!("internal error: {detail}"),
details: ErrorDetails::Internal,
cause: None,
}
}
}
pub type NodeDbResult<T> = std::result::Result<T, NodeDbError>;
impl From<std::io::Error> for NodeDbError {
fn from(e: std::io::Error) -> Self {
Self::storage(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_code_display() {
assert_eq!(ErrorCode::CONSTRAINT_VIOLATION.to_string(), "NDB-1000");
assert_eq!(ErrorCode::INTERNAL.to_string(), "NDB-9000");
assert_eq!(ErrorCode::WAL.to_string(), "NDB-4100");
}
#[test]
fn error_display_includes_code() {
let e = NodeDbError::constraint_violation("users", "duplicate email");
let msg = e.to_string();
assert!(msg.contains("NDB-1000"));
assert!(msg.contains("constraint violation"));
assert!(msg.contains("users"));
}
#[test]
fn error_code_accessor() {
let e = NodeDbError::write_conflict("orders", "abc");
assert_eq!(e.code(), ErrorCode::WRITE_CONFLICT);
assert_eq!(e.code().0, 1001);
}
#[test]
fn details_matching() {
let e = NodeDbError::collection_not_found("users");
assert!(matches!(
e.details(),
ErrorDetails::CollectionNotFound { collection } if collection == "users"
));
assert!(e.is_not_found());
}
#[test]
fn error_cause_chaining() {
let inner = NodeDbError::storage("disk full");
let outer = NodeDbError::internal("write failed").with_cause(inner);
assert!(outer.cause().is_some());
assert!(outer.cause().unwrap().is_storage());
assert!(outer.cause().unwrap().message().contains("disk full"));
}
#[test]
fn sync_delta_rejected() {
let e = NodeDbError::sync_delta_rejected(
"unique violation",
Some(
crate::sync::compensation::CompensationHint::UniqueViolation {
field: "email".into(),
conflicting_value: "a@b.com".into(),
},
),
);
assert!(e.to_string().contains("NDB-3001"));
assert!(e.to_string().contains("sync delta rejected"));
}
#[test]
fn sql_not_enabled() {
let e = NodeDbError::sql_not_enabled();
assert!(e.to_string().contains("SQL not enabled"));
assert_eq!(e.code(), ErrorCode::SQL_NOT_ENABLED);
}
#[test]
fn io_error_converts() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
let e: NodeDbError = io_err.into();
assert!(e.is_storage());
assert_eq!(e.code(), ErrorCode::STORAGE);
}
#[test]
fn retriable_errors() {
assert!(NodeDbError::write_conflict("x", "y").is_retriable());
assert!(NodeDbError::deadline_exceeded().is_retriable());
assert!(!NodeDbError::bad_request("bad").is_retriable());
}
#[test]
fn client_errors() {
assert!(NodeDbError::bad_request("bad").is_client_error());
assert!(!NodeDbError::internal("oops").is_client_error());
}
#[test]
fn json_serialization() {
let e = NodeDbError::collection_not_found("users");
let json = serde_json::to_value(&e).unwrap();
assert_eq!(json["code"], 1100);
assert!(json["message"].as_str().unwrap().contains("users"));
assert_eq!(json["details"]["kind"], "collection_not_found");
assert_eq!(json["details"]["collection"], "users");
}
}