Skip to main content

lora_database/
error.rs

1//! Top-level error type and stable error-code catalog for LoraDB.
2//!
3//! Internal `lora-database` code still produces `anyhow::Result` because
4//! `?`-chaining over many lower-layer error types is convenient. The
5//! public boundary, however, surfaces a typed [`LoraError`] so transports
6//! and bindings can route on the stable [`LoraErrorCode`] wire string
7//! without parsing message text.
8//!
9//! # Stable contract
10//!
11//! - [`LoraErrorCode::as_str`] returns the wire string. These strings are
12//!   part of the public API and never change between releases.
13//! - [`LoraError::message`] returns a user-friendly clause. Wording **may**
14//!   change between minor versions to improve clarity — bindings and
15//!   integration tests must not match on it.
16//! - [`LoraError::category`] returns whether the failure was the caller's
17//!   fault (`Client`) or the engine's (`Server`). The HTTP layer uses this
18//!   to pick the response status; bindings can use it to tag exceptions.
19//!
20//! See `docs/design/error-style.md` for the message style guide.
21
22use std::error::Error;
23use std::fmt;
24
25use lora_analyzer::SemanticError;
26use lora_executor::ExecutorError;
27use lora_parser::ParseError;
28use lora_snapshot::SnapshotCodecError;
29use lora_store::SnapshotError;
30use lora_wal::{WalBufferedCommitError, WalCommitError, WalError};
31
32use crate::transaction::TransactionError;
33use crate::DatabaseNameError;
34
35/// Stable error-code catalog. The wire string returned by [`Self::as_str`]
36/// is part of LoraDB's public API. Consumers — bindings, the HTTP layer,
37/// integration tests — should match on this rather than on the message
38/// text, which is allowed to change between releases.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum LoraErrorCode {
41    // -------- Client errors --------
42    /// Cypher syntax could not be parsed.
43    Parse,
44    /// Cypher analysis (unknown variable, label, function, type mismatch, …).
45    Semantic,
46    /// A parameter value passed by the caller could not be coerced.
47    InvalidParams,
48    /// A mutating statement was issued in a read-only context.
49    ReadOnlyViolation,
50    /// A named entity (database, label, key) does not exist.
51    NotFound,
52    /// A precondition (e.g. delete-with-relationships) is not satisfied.
53    ConstraintViolation,
54    /// A vector value failed dimension / coordinate-type validation.
55    InvalidVector,
56    /// A query exceeded its cooperative deadline.
57    Timeout,
58    /// A logical database name violates the portable-path rules.
59    DatabaseName,
60    /// Required parameters are missing or malformed (CLI / config flags).
61    Config,
62
63    // -------- Server errors --------
64    /// I/O failure outside the WAL / snapshot boundaries.
65    Io,
66    /// WAL record was truncated, mis-CRC'd, or otherwise unreadable.
67    WalCorruption,
68    /// The WAL is poisoned and no longer accepts durable writes.
69    WalPoisoned,
70    /// Snapshot codec failure (bad magic, version, checksum, …).
71    SnapshotCodec,
72    /// Snapshot encryption / decryption / KDF failure.
73    SnapshotCrypto,
74    /// Last-resort fallback when the engine cannot classify the failure.
75    Internal,
76}
77
78/// Whether a [`LoraErrorCode`] represents a caller-visible mistake or an
79/// engine-side failure. Used by the HTTP transport to choose between
80/// 4xx and 5xx status codes.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum LoraErrorCategory {
83    Client,
84    Server,
85}
86
87impl LoraErrorCategory {
88    pub fn as_str(self) -> &'static str {
89        match self {
90            Self::Client => "client",
91            Self::Server => "server",
92        }
93    }
94}
95
96impl LoraErrorCode {
97    /// Stable wire string. Part of the public API — never changes.
98    pub fn as_str(self) -> &'static str {
99        match self {
100            Self::Parse => "LORA_PARSE",
101            Self::Semantic => "LORA_SEMANTIC",
102            Self::InvalidParams => "LORA_INVALID_PARAMS",
103            Self::ReadOnlyViolation => "LORA_READ_ONLY",
104            Self::NotFound => "LORA_NOT_FOUND",
105            Self::ConstraintViolation => "LORA_CONSTRAINT",
106            Self::InvalidVector => "LORA_INVALID_VECTOR",
107            Self::Timeout => "LORA_TIMEOUT",
108            Self::DatabaseName => "LORA_DATABASE_NAME",
109            Self::Config => "LORA_CONFIG",
110            Self::Io => "LORA_IO",
111            Self::WalCorruption => "LORA_WAL_CORRUPTION",
112            Self::WalPoisoned => "LORA_WAL_POISONED",
113            Self::SnapshotCodec => "LORA_SNAPSHOT_CODEC",
114            Self::SnapshotCrypto => "LORA_SNAPSHOT_CRYPTO",
115            Self::Internal => "LORA_INTERNAL",
116        }
117    }
118
119    /// Whether this code is the caller's fault or the engine's.
120    pub fn category(self) -> LoraErrorCategory {
121        match self {
122            Self::Parse
123            | Self::Semantic
124            | Self::InvalidParams
125            | Self::ReadOnlyViolation
126            | Self::NotFound
127            | Self::ConstraintViolation
128            | Self::InvalidVector
129            | Self::Timeout
130            | Self::DatabaseName
131            | Self::Config => LoraErrorCategory::Client,
132            Self::Io
133            | Self::WalCorruption
134            | Self::WalPoisoned
135            | Self::SnapshotCodec
136            | Self::SnapshotCrypto
137            | Self::Internal => LoraErrorCategory::Server,
138        }
139    }
140}
141
142impl fmt::Display for LoraErrorCode {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.write_str(self.as_str())
145    }
146}
147
148/// Public error type at the `lora-database` boundary.
149///
150/// Construct via [`Self::from_anyhow`] (the typical path — the engine
151/// uses `anyhow::Error` internally) or via the `From` impls for any
152/// known concrete error type.
153pub struct LoraError {
154    code: LoraErrorCode,
155    message: String,
156    source: Option<Box<dyn Error + Send + Sync + 'static>>,
157}
158
159impl LoraError {
160    pub fn new(code: LoraErrorCode, message: impl Into<String>) -> Self {
161        Self {
162            code,
163            message: message.into(),
164            source: None,
165        }
166    }
167
168    pub fn with_source(
169        code: LoraErrorCode,
170        message: impl Into<String>,
171        source: impl Error + Send + Sync + 'static,
172    ) -> Self {
173        Self {
174            code,
175            message: message.into(),
176            source: Some(Box::new(source)),
177        }
178    }
179
180    pub fn code(&self) -> LoraErrorCode {
181        self.code
182    }
183
184    pub fn message(&self) -> &str {
185        &self.message
186    }
187
188    pub fn category(&self) -> LoraErrorCategory {
189        self.code.category()
190    }
191
192    /// Convert an `anyhow::Error` from the engine's internal `?`-chains
193    /// into a typed `LoraError`. Best-effort: downcasts the chain to any
194    /// known concrete error type and picks the matching code; falls back
195    /// to [`LoraErrorCode::Internal`] with the original message preserved.
196    pub fn from_anyhow(err: anyhow::Error) -> Self {
197        Self::from_anyhow_ref(&err)
198    }
199
200    /// Borrowed version of [`Self::from_anyhow`]. Useful in binding
201    /// layers that hold `&anyhow::Error` from a `Result::Err` capture
202    /// and don't want to move the error.
203    pub fn from_anyhow_ref(err: &anyhow::Error) -> Self {
204        // If the chain already carries a typed `LoraError` (because some
205        // intermediate layer wrapped one with `?` or `.into()`), preserve
206        // its code rather than re-classifying as `Internal`.
207        if let Some(e) = err.downcast_ref::<LoraError>() {
208            return Self::new(e.code, e.message.clone());
209        }
210        if let Some(e) = err.downcast_ref::<ParseError>() {
211            return Self::new(LoraErrorCode::Parse, e.to_string());
212        }
213        if let Some(e) = err.downcast_ref::<SemanticError>() {
214            return Self::new(LoraErrorCode::Semantic, e.to_string());
215        }
216        if let Some(e) = err.downcast_ref::<ExecutorError>() {
217            return Self::new(executor_code(e), e.to_string());
218        }
219        if let Some(e) = err.downcast_ref::<WalError>() {
220            return Self::new(wal_code(e), e.to_string());
221        }
222        if let Some(e) = err.downcast_ref::<WalCommitError>() {
223            return Self::new(wal_commit_code(e), e.to_string());
224        }
225        if let Some(e) = err.downcast_ref::<WalBufferedCommitError>() {
226            return Self::new(wal_buffered_commit_code(e), e.to_string());
227        }
228        if let Some(e) = err.downcast_ref::<SnapshotCodecError>() {
229            return Self::new(snapshot_codec_code(e), e.to_string());
230        }
231        if let Some(e) = err.downcast_ref::<SnapshotError>() {
232            return Self::new(snapshot_store_code(e), e.to_string());
233        }
234        if let Some(e) = err.downcast_ref::<DatabaseNameError>() {
235            return Self::new(LoraErrorCode::DatabaseName, e.to_string());
236        }
237        if let Some(e) = err.downcast_ref::<TransactionError>() {
238            return Self::new(transaction_code(e), e.to_string());
239        }
240        if let Some(e) = err.downcast_ref::<std::io::Error>() {
241            return Self::new(LoraErrorCode::Io, e.to_string());
242        }
243        // Fallback: an external `anyhow::Error` we don't recognise. Internal
244        // sites all surface typed errors that the downcasts above route
245        // precisely, so anything that lands here is from a third-party crate
246        // or a legacy `anyhow!("...")` we have not yet converted.
247        Self::new(LoraErrorCode::Internal, format!("{err:#}"))
248    }
249}
250
251fn executor_code(err: &ExecutorError) -> LoraErrorCode {
252    match err {
253        ExecutorError::ReadOnlyCreate { .. }
254        | ExecutorError::ReadOnlyMerge { .. }
255        | ExecutorError::ReadOnlyDelete { .. }
256        | ExecutorError::ReadOnlySet { .. }
257        | ExecutorError::ReadOnlyRemove { .. } => LoraErrorCode::ReadOnlyViolation,
258        ExecutorError::QueryTimeout => LoraErrorCode::Timeout,
259        ExecutorError::DeleteNodeWithRelationships { .. } => LoraErrorCode::ConstraintViolation,
260        _ => LoraErrorCode::Internal,
261    }
262}
263
264fn wal_code(err: &WalError) -> LoraErrorCode {
265    match err {
266        WalError::Io(_) | WalError::AlreadyOpen { .. } => LoraErrorCode::Io,
267        WalError::CrcMismatch { .. }
268        | WalError::Truncated { .. }
269        | WalError::UnknownKind(_)
270        | WalError::BadSegmentHeader(_)
271        | WalError::Malformed(_)
272        | WalError::Encode(_)
273        | WalError::Decode(_) => LoraErrorCode::WalCorruption,
274        WalError::Poisoned => LoraErrorCode::WalPoisoned,
275    }
276}
277
278fn wal_commit_code(err: &WalCommitError) -> LoraErrorCode {
279    match err {
280        WalCommitError::Commit(inner) | WalCommitError::Flush(inner) => wal_code(inner),
281    }
282}
283
284fn wal_buffered_commit_code(err: &WalBufferedCommitError) -> LoraErrorCode {
285    match err {
286        WalBufferedCommitError::Arm(inner) => wal_code(inner),
287        WalBufferedCommitError::Poisoned(_) | WalBufferedCommitError::ReplayPoisoned(_) => {
288            LoraErrorCode::WalPoisoned
289        }
290        WalBufferedCommitError::Commit(inner) => wal_commit_code(inner),
291    }
292}
293
294fn snapshot_codec_code(err: &SnapshotCodecError) -> LoraErrorCode {
295    match err {
296        SnapshotCodecError::Io(_) => LoraErrorCode::Io,
297        SnapshotCodecError::MissingEncryptionKey(_)
298        | SnapshotCodecError::MissingPassword(_)
299        | SnapshotCodecError::PasswordKdf(_)
300        | SnapshotCodecError::Encrypt
301        | SnapshotCodecError::Decrypt => LoraErrorCode::SnapshotCrypto,
302        SnapshotCodecError::BadMagic
303        | SnapshotCodecError::UnsupportedVersion(_)
304        | SnapshotCodecError::UnsupportedCompression(_)
305        | SnapshotCodecError::ChecksumMismatch
306        | SnapshotCodecError::Encode(_)
307        | SnapshotCodecError::Decode(_) => LoraErrorCode::SnapshotCodec,
308    }
309}
310
311fn snapshot_store_code(err: &SnapshotError) -> LoraErrorCode {
312    match err {
313        SnapshotError::Io(_) => LoraErrorCode::Io,
314        SnapshotError::Decode(_) | SnapshotError::Encode(_) => LoraErrorCode::SnapshotCodec,
315    }
316}
317
318fn transaction_code(err: &TransactionError) -> LoraErrorCode {
319    match err {
320        TransactionError::ReadOnlyMutation
321        | TransactionError::ReadOnlyCommit
322        | TransactionError::StreamingRequiresReadWrite => LoraErrorCode::ReadOnlyViolation,
323        TransactionError::AlreadyClosed
324        | TransactionError::NoGraphGuard
325        | TransactionError::NoStagedGraph
326        | TransactionError::CursorActiveCommit
327        | TransactionError::CursorActiveStatement
328        | TransactionError::Poisoned => LoraErrorCode::Internal,
329    }
330}
331
332impl fmt::Debug for LoraError {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        f.debug_struct("LoraError")
335            .field("code", &self.code)
336            .field("message", &self.message)
337            .finish()
338    }
339}
340
341impl fmt::Display for LoraError {
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        f.write_str(&self.message)
344    }
345}
346
347impl Error for LoraError {
348    fn source(&self) -> Option<&(dyn Error + 'static)> {
349        self.source.as_deref().map(|s| s as &(dyn Error + 'static))
350    }
351}
352
353// -------- From impls for direct construction --------
354
355impl From<ParseError> for LoraError {
356    fn from(e: ParseError) -> Self {
357        let msg = e.to_string();
358        Self::with_source(LoraErrorCode::Parse, msg, e)
359    }
360}
361
362impl From<SemanticError> for LoraError {
363    fn from(e: SemanticError) -> Self {
364        let msg = e.to_string();
365        Self::with_source(LoraErrorCode::Semantic, msg, e)
366    }
367}
368
369impl From<ExecutorError> for LoraError {
370    fn from(e: ExecutorError) -> Self {
371        let code = executor_code(&e);
372        let msg = e.to_string();
373        Self::with_source(code, msg, e)
374    }
375}
376
377impl From<WalError> for LoraError {
378    fn from(e: WalError) -> Self {
379        let code = wal_code(&e);
380        let msg = e.to_string();
381        Self::with_source(code, msg, e)
382    }
383}
384
385impl From<SnapshotCodecError> for LoraError {
386    fn from(e: SnapshotCodecError) -> Self {
387        let code = snapshot_codec_code(&e);
388        let msg = e.to_string();
389        Self::with_source(code, msg, e)
390    }
391}
392
393impl From<SnapshotError> for LoraError {
394    fn from(e: SnapshotError) -> Self {
395        let code = snapshot_store_code(&e);
396        let msg = e.to_string();
397        Self::with_source(code, msg, e)
398    }
399}
400
401impl From<DatabaseNameError> for LoraError {
402    fn from(e: DatabaseNameError) -> Self {
403        let msg = e.to_string();
404        Self::with_source(LoraErrorCode::DatabaseName, msg, e)
405    }
406}
407
408impl From<TransactionError> for LoraError {
409    fn from(e: TransactionError) -> Self {
410        let code = transaction_code(&e);
411        let msg = e.to_string();
412        Self::with_source(code, msg, e)
413    }
414}
415
416impl From<std::io::Error> for LoraError {
417    fn from(e: std::io::Error) -> Self {
418        let msg = e.to_string();
419        Self::with_source(LoraErrorCode::Io, msg, e)
420    }
421}
422
423impl From<anyhow::Error> for LoraError {
424    fn from(e: anyhow::Error) -> Self {
425        Self::from_anyhow(e)
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn parse_error_is_client_parse() {
435        let e = ParseError::new("expected `MATCH`", 0, 5);
436        let mapped: LoraError = anyhow::Error::from(e).into();
437        assert_eq!(mapped.code(), LoraErrorCode::Parse);
438        assert_eq!(mapped.category(), LoraErrorCategory::Client);
439        assert!(mapped.message().contains("parse error"));
440    }
441
442    #[test]
443    fn semantic_error_is_client_semantic() {
444        let e = SemanticError::UnknownVariable("n".into());
445        let mapped = LoraError::from(e);
446        assert_eq!(mapped.code(), LoraErrorCode::Semantic);
447        assert_eq!(mapped.message(), "unknown variable `n`");
448    }
449
450    #[test]
451    fn executor_timeout_is_client_timeout() {
452        let mapped = LoraError::from(ExecutorError::QueryTimeout);
453        assert_eq!(mapped.code(), LoraErrorCode::Timeout);
454    }
455
456    #[test]
457    fn wal_io_is_server_io() {
458        let inner = std::io::Error::other("disk full");
459        let mapped = LoraError::from(WalError::Io(inner));
460        assert_eq!(mapped.code(), LoraErrorCode::Io);
461        assert_eq!(mapped.category(), LoraErrorCategory::Server);
462    }
463
464    #[test]
465    fn unknown_anyhow_falls_back_to_internal() {
466        let e = anyhow::anyhow!("something else entirely");
467        let mapped = LoraError::from_anyhow(e);
468        assert_eq!(mapped.code(), LoraErrorCode::Internal);
469    }
470
471    #[test]
472    fn typed_transaction_error_routes_readonly() {
473        let mapped = LoraError::from(TransactionError::ReadOnlyMutation);
474        assert_eq!(mapped.code(), LoraErrorCode::ReadOnlyViolation);
475        assert_eq!(
476            mapped.message(),
477            "cannot execute mutating query in read-only transaction"
478        );
479    }
480
481    #[test]
482    fn typed_transaction_error_round_trips_through_anyhow() {
483        let any: anyhow::Error = TransactionError::AlreadyClosed.into();
484        let mapped = LoraError::from_anyhow(any);
485        assert_eq!(mapped.code(), LoraErrorCode::Internal);
486        assert_eq!(mapped.message(), "transaction is already closed");
487    }
488
489    #[test]
490    fn code_wire_strings_are_stable() {
491        // Sanity check: these strings are part of the public API and
492        // must not change between releases.
493        assert_eq!(LoraErrorCode::Parse.as_str(), "LORA_PARSE");
494        assert_eq!(LoraErrorCode::Timeout.as_str(), "LORA_TIMEOUT");
495        assert_eq!(LoraErrorCode::WalPoisoned.as_str(), "LORA_WAL_POISONED");
496        assert_eq!(LoraErrorCode::Internal.as_str(), "LORA_INTERNAL");
497    }
498}