Skip to main content

sqlite_graphrag/
errors.rs

1//! Library-wide error type.
2//!
3//! `AppError` is the single error type returned by every public API in the
4//! crate. Each variant maps to a deterministic exit code through
5//! `AppError::exit_code`, which the binary propagates to the shell on
6//! failure. See the README for the full exit code contract.
7
8use crate::i18n::{current, Language};
9use thiserror::Error;
10
11/// Unified error type for all CLI and library operations.
12///
13/// Each variant corresponds to a distinct failure category. The
14/// [`AppError::exit_code`] method converts a variant into a stable numeric
15/// code so that shell callers and LLM agents can route on it.
16///
17/// # SemVer Policy
18///
19/// This enum is `#[non_exhaustive]`. New variants may be added in minor
20/// releases without breaking downstream match arms (use a wildcard `_`).
21#[derive(Error, Debug)]
22#[non_exhaustive]
23pub enum AppError {
24    /// Input failed schema, length or format validation. Maps to exit code `1`.
25    ///
26    /// This variant groups multiple validation failure causes. Callers that need
27    /// programmatic retry decisions should use [`AppError::is_retryable`] instead
28    /// of parsing the message string.
29    #[error("validation error: {0}")]
30    Validation(String),
31
32    /// External binary required for operation was not found in PATH. Maps to exit code `1`.
33    #[error("binary not found: {name} — ensure it is installed and in PATH")]
34    BinaryNotFound { name: String },
35
36    /// Remote service signaled rate limiting; caller should retry with backoff. Maps to exit code `1`.
37    #[error("rate limited: {detail}")]
38    RateLimited { detail: String },
39
40    /// Operation exceeded its time budget. Maps to exit code `1`.
41    #[error("timeout after {duration_secs}s: {operation}")]
42    Timeout {
43        operation: String,
44        duration_secs: u64,
45    },
46
47    /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `9`.
48    #[error("duplicate detected: {0}")]
49    Duplicate(String),
50
51    /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
52    #[error("conflict: {0}")]
53    Conflict(String),
54
55    /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
56    #[error("not found: {0}")]
57    NotFound(String),
58
59    /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
60    #[error("namespace not resolved: {0}")]
61    NamespaceError(String),
62
63    /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
64    #[error("limit exceeded: {0}")]
65    LimitExceeded(String),
66
67    /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
68    #[error("database error: {0}")]
69    Database(#[from] rusqlite::Error),
70
71    /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
72    #[error("embedding error: {0}")]
73    Embedding(String),
74
75    /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
76    #[error("sqlite-vec extension failed: {0}")]
77    VecExtension(String),
78
79    /// SQLite returned `SQLITE_BUSY` after exhausting retries. Maps to exit code `15` (was `13` before v2.0.0; relocated to free `13` for BatchPartialFailure per PRD).
80    #[error("database busy: {0}")]
81    DbBusy(String),
82
83    /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
84    ///
85    /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
86    /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
87    #[error("batch partial failure: {failed} of {total} items failed")]
88    BatchPartialFailure { total: usize, failed: usize },
89
90    /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
91    #[error("IO error: {0}")]
92    Io(#[from] std::io::Error),
93
94    /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
95    #[error(transparent)]
96    Internal(#[from] anyhow::Error),
97
98    /// JSON serialization or deserialization failure. Maps to exit code `20`.
99    #[error("json error: {0}")]
100    Json(#[from] serde_json::Error),
101
102    /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
103    ///
104    /// Use `--allow-parallel` to skip the lock or `--wait-lock SECONDS` to retry.
105    #[error("lock busy: {0}")]
106    LockBusy(String),
107
108    /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
109    ///
110    /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
111    /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
112    #[error(
113        "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
114         use --max-concurrency or wait for other invocations to finish"
115    )]
116    AllSlotsFull { max: usize, waited_secs: u64 },
117
118    /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
119    ///
120    /// Returned when `sysinfo` reports available memory below
121    /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
122    #[error(
123        "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
124         to load the model; abort other loads or use --skip-memory-guard (exit 77)"
125    )]
126    LowMemory { available_mb: u64, required_mb: u64 },
127}
128
129impl AppError {
130    /// Returns the deterministic process exit code for this error variant.
131    ///
132    /// The codes follow the contract documented in the README: `1` for
133    /// validation, `9` for duplicates (moved from `2` in v1.0.52), `3` for conflicts, `4` for missing
134    /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
135    /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
136    /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
137    /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
138    /// slots are exhausted, and `77` when available memory is insufficient to
139    /// load the embedding model.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use sqlite_graphrag::errors::AppError;
145    ///
146    /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
147    /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 9);
148    /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
149    /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
150    /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
151    /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
152    /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
153    /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
154    /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
155    /// ```
156    #[inline]
157    #[must_use]
158    pub fn exit_code(&self) -> i32 {
159        match self {
160            Self::Validation(_) => 1,
161            Self::BinaryNotFound { .. } => 1,
162            Self::RateLimited { .. } => 1,
163            Self::Timeout { .. } => 1,
164            Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
165            Self::Conflict(_) => 3,
166            Self::NotFound(_) => 4,
167            Self::NamespaceError(_) => 5,
168            Self::LimitExceeded(_) => 6,
169            Self::Database(_) => 10,
170            Self::Embedding(_) => 11,
171            Self::VecExtension(_) => 12,
172            Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
173            Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
174            Self::Io(_) => 14,
175            Self::Internal(_) => 20,
176            Self::Json(_) => 20,
177            Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
178            Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
179            Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
180        }
181    }
182
183    /// Returns `true` when the error is transient and the operation may
184    /// succeed on retry with backoff.
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// use sqlite_graphrag::errors::AppError;
190    ///
191    /// assert!(AppError::DbBusy("busy".into()).is_retryable());
192    /// assert!(AppError::LockBusy("held".into()).is_retryable());
193    /// assert!(!AppError::NotFound("x".into()).is_retryable());
194    /// assert!(!AppError::Validation("bad".into()).is_retryable());
195    /// ```
196    #[inline]
197    #[must_use]
198    pub fn is_retryable(&self) -> bool {
199        matches!(
200            self,
201            Self::DbBusy(_)
202                | Self::LockBusy(_)
203                | Self::AllSlotsFull { .. }
204                | Self::LowMemory { .. }
205                | Self::RateLimited { .. }
206                | Self::Timeout { .. }
207        )
208    }
209
210    /// Returns `true` when the error is permanent and must NOT be retried.
211    ///
212    /// Complement to [`Self::is_retryable`]. Errors not classified by either
213    /// method (e.g. `Database`, `Io`, `Internal`) are ambiguous — the caller
214    /// decides based on context.
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// use sqlite_graphrag::errors::AppError;
220    ///
221    /// assert!(AppError::Validation("bad".into()).is_permanent());
222    /// assert!(!AppError::DbBusy("busy".into()).is_permanent());
223    /// ```
224    #[inline]
225    #[must_use]
226    pub fn is_permanent(&self) -> bool {
227        matches!(
228            self,
229            Self::Validation(_)
230                | Self::BinaryNotFound { .. }
231                | Self::Duplicate(_)
232                | Self::NotFound(_)
233                | Self::NamespaceError(_)
234                | Self::LimitExceeded(_)
235                | Self::VecExtension(_)
236        )
237    }
238
239    /// Returns the localized error message in the active language (`--lang` / `SQLITE_GRAPHRAG_LANG`).
240    ///
241    /// In English the text is identical to the `Display` generated by thiserror.
242    /// In Portuguese the prefixes and messages are translated to PT-BR.
243    pub fn localized_message(&self) -> String {
244        self.localized_message_for(current())
245    }
246
247    /// Returns the localized message for the explicitly provided language.
248    /// Useful in tests that cannot depend on the global `OnceLock`.
249    ///
250    /// # Examples
251    ///
252    /// ```
253    /// use sqlite_graphrag::errors::AppError;
254    /// use sqlite_graphrag::i18n::Language;
255    ///
256    /// let err = AppError::NotFound("mem-xyz".into());
257    ///
258    /// let en = err.localized_message_for(Language::English);
259    /// assert!(en.contains("not found"));
260    ///
261    /// let pt = err.localized_message_for(Language::Portuguese);
262    /// assert!(pt.contains("n\u{e3}o encontrado"));
263    /// ```
264    pub fn localized_message_for(&self, lang: Language) -> String {
265        match lang {
266            Language::English => self.to_string(),
267            Language::Portuguese => self.to_string_pt(),
268        }
269    }
270
271    fn to_string_pt(&self) -> String {
272        use crate::i18n::validation::app_error_pt as pt;
273        match self {
274            Self::Validation(msg) => pt::validation(msg),
275            Self::BinaryNotFound { name } => pt::binary_not_found(name),
276            Self::RateLimited { detail } => pt::rate_limited(detail),
277            Self::Timeout {
278                operation,
279                duration_secs,
280            } => pt::timeout(operation, *duration_secs),
281            Self::Duplicate(msg) => pt::duplicate(msg),
282            Self::Conflict(msg) => pt::conflict(msg),
283            Self::NotFound(msg) => pt::not_found(msg),
284            Self::NamespaceError(msg) => pt::namespace_error(msg),
285            Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
286            Self::Database(e) => pt::database(&e.to_string()),
287            Self::Embedding(msg) => pt::embedding(msg),
288            Self::VecExtension(msg) => pt::vec_extension(msg),
289            Self::DbBusy(msg) => pt::db_busy(msg),
290            Self::BatchPartialFailure { total, failed } => {
291                pt::batch_partial_failure(*total, *failed)
292            }
293            Self::Io(e) => pt::io(&e.to_string()),
294            Self::Internal(e) => pt::internal(&e.to_string()),
295            Self::Json(e) => pt::json(&e.to_string()),
296            Self::LockBusy(msg) => pt::lock_busy(msg),
297            Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
298            Self::LowMemory {
299                available_mb,
300                required_mb,
301            } => pt::low_memory(*available_mb, *required_mb),
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use std::io;
310
311    #[test]
312    fn exit_code_validation_returns_1() {
313        assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
314    }
315
316    #[test]
317    fn exit_code_duplicate_returns_9() {
318        assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
319    }
320
321    #[test]
322    fn exit_code_conflict_returns_3() {
323        assert_eq!(
324            AppError::Conflict("updated_at changed".into()).exit_code(),
325            3
326        );
327    }
328
329    #[test]
330    fn exit_code_not_found_returns_4() {
331        assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
332    }
333
334    #[test]
335    fn exit_code_namespace_error_returns_5() {
336        assert_eq!(
337            AppError::NamespaceError("not resolved".into()).exit_code(),
338            5
339        );
340    }
341
342    #[test]
343    fn exit_code_limit_exceeded_returns_6() {
344        assert_eq!(
345            AppError::LimitExceeded("body too large".into()).exit_code(),
346            6
347        );
348    }
349
350    #[test]
351    fn exit_code_embedding_returns_11() {
352        assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
353    }
354
355    #[test]
356    fn exit_code_vec_extension_returns_12() {
357        assert_eq!(
358            AppError::VecExtension("extension did not load".into()).exit_code(),
359            12
360        );
361    }
362
363    #[test]
364    fn exit_code_db_busy_returns_15() {
365        assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
366    }
367
368    #[test]
369    fn exit_code_batch_partial_failure_returns_13() {
370        assert_eq!(
371            AppError::BatchPartialFailure {
372                total: 10,
373                failed: 3
374            }
375            .exit_code(),
376            13
377        );
378    }
379
380    #[test]
381    fn display_batch_partial_failure_includes_counts() {
382        let err = AppError::BatchPartialFailure {
383            total: 50,
384            failed: 7,
385        };
386        let msg = err.to_string();
387        assert!(msg.contains("7"));
388        assert!(msg.contains("50"));
389        // to_string() uses the English #[error] attr; PT is in localized_message_for
390        assert!(msg.contains("batch partial failure"));
391    }
392
393    #[test]
394    fn exit_code_io_returns_14() {
395        let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
396        assert_eq!(AppError::Io(io_err).exit_code(), 14);
397    }
398
399    #[test]
400    fn exit_code_internal_returns_20() {
401        let anyhow_err = anyhow::anyhow!("unexpected internal error");
402        assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
403    }
404
405    #[test]
406    fn exit_code_json_returns_20() {
407        let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
408        assert_eq!(AppError::Json(json_err).exit_code(), 20);
409    }
410
411    #[test]
412    fn exit_code_lock_busy_returns_75() {
413        assert_eq!(
414            AppError::LockBusy("another active instance".into()).exit_code(),
415            75
416        );
417    }
418
419    #[test]
420    fn display_validation_includes_message() {
421        let err = AppError::Validation("invalid id".into());
422        assert!(err.to_string().contains("invalid id"));
423        assert!(err.to_string().contains("validation error"));
424    }
425
426    #[test]
427    fn display_duplicate_includes_message() {
428        let err = AppError::Duplicate("proj/mem".into());
429        assert!(err.to_string().contains("proj/mem"));
430        assert!(err.to_string().contains("duplicate detected"));
431    }
432
433    #[test]
434    fn display_not_found_includes_message() {
435        let err = AppError::NotFound("id 42".into());
436        assert!(err.to_string().contains("id 42"));
437        assert!(err.to_string().contains("not found"));
438    }
439
440    #[test]
441    fn display_embedding_includes_message() {
442        let err = AppError::Embedding("wrong dimension".into());
443        assert!(err.to_string().contains("wrong dimension"));
444        assert!(err.to_string().contains("embedding error"));
445    }
446
447    #[test]
448    fn display_lock_busy_includes_message() {
449        let err = AppError::LockBusy("pid 1234".into());
450        assert!(err.to_string().contains("pid 1234"));
451        assert!(err.to_string().contains("lock busy"));
452    }
453
454    #[test]
455    fn from_io_error_converts_correctly() {
456        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
457        let app_err: AppError = io_err.into();
458        assert_eq!(app_err.exit_code(), 14);
459        assert!(app_err.to_string().contains("IO error"));
460    }
461
462    #[test]
463    fn from_anyhow_error_converts_correctly() {
464        let anyhow_err = anyhow::anyhow!("internal detail");
465        let app_err: AppError = anyhow_err.into();
466        assert_eq!(app_err.exit_code(), 20);
467        assert!(app_err.to_string().contains("internal detail"));
468    }
469
470    #[test]
471    fn from_serde_json_error_converts_correctly() {
472        let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
473        let app_err: AppError = json_err.into();
474        assert_eq!(app_err.exit_code(), 20);
475        assert!(app_err.to_string().contains("json error"));
476    }
477
478    #[test]
479    fn exit_code_lock_busy_matches_constant() {
480        assert_eq!(
481            AppError::LockBusy("test".into()).exit_code(),
482            crate::constants::CLI_LOCK_EXIT_CODE
483        );
484    }
485
486    #[test]
487    fn localized_message_en_equals_to_string() {
488        let err = AppError::NotFound("mem-x".into());
489        assert_eq!(
490            err.localized_message_for(crate::i18n::Language::English),
491            err.to_string()
492        );
493    }
494
495    // Detailed Portuguese-specific assertions live in `src/i18n.rs`
496    // (the bilingual module). Here we only verify that delegation is wired
497    // correctly, without embedding PT strings in this English-only file.
498
499    #[test]
500    fn localized_message_pt_differs_from_en() {
501        let err = AppError::NotFound("mem-x".into());
502        let en = err.localized_message_for(crate::i18n::Language::English);
503        let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
504        assert_ne!(en, pt, "PT and EN must produce distinct messages");
505        assert!(pt.contains("mem-x"), "PT must include the variant payload");
506    }
507
508    #[test]
509    fn localized_message_pt_delegates_to_app_error_pt_helper() {
510        use crate::i18n::validation::app_error_pt as pt;
511
512        let cases: Vec<(AppError, String)> = vec![
513            (AppError::Validation("x".into()), pt::validation("x")),
514            (AppError::Duplicate("x".into()), pt::duplicate("x")),
515            (AppError::Conflict("x".into()), pt::conflict("x")),
516            (AppError::NotFound("x".into()), pt::not_found("x")),
517            (
518                AppError::NamespaceError("x".into()),
519                pt::namespace_error("x"),
520            ),
521            (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
522            (AppError::Embedding("x".into()), pt::embedding("x")),
523            (AppError::VecExtension("x".into()), pt::vec_extension("x")),
524            (AppError::DbBusy("x".into()), pt::db_busy("x")),
525            (
526                AppError::BatchPartialFailure {
527                    total: 10,
528                    failed: 3,
529                },
530                pt::batch_partial_failure(10, 3),
531            ),
532            (AppError::LockBusy("x".into()), pt::lock_busy("x")),
533            (
534                AppError::AllSlotsFull {
535                    max: 4,
536                    waited_secs: 60,
537                },
538                pt::all_slots_full(4, 60),
539            ),
540            (
541                AppError::LowMemory {
542                    available_mb: 100,
543                    required_mb: 500,
544                },
545                pt::low_memory(100, 500),
546            ),
547            (
548                AppError::BinaryNotFound {
549                    name: "claude".into(),
550                },
551                pt::binary_not_found("claude"),
552            ),
553            (
554                AppError::RateLimited {
555                    detail: "429".into(),
556                },
557                pt::rate_limited("429"),
558            ),
559            (
560                AppError::Timeout {
561                    operation: "op".into(),
562                    duration_secs: 30,
563                },
564                pt::timeout("op", 30),
565            ),
566        ];
567
568        for (err, expected) in cases {
569            let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
570            assert_eq!(actual, expected, "delegation mismatch");
571        }
572    }
573
574    #[test]
575    fn is_retryable_transient_errors() {
576        assert!(AppError::DbBusy("x".into()).is_retryable());
577        assert!(AppError::LockBusy("x".into()).is_retryable());
578        assert!(AppError::AllSlotsFull {
579            max: 4,
580            waited_secs: 60
581        }
582        .is_retryable());
583        assert!(AppError::LowMemory {
584            available_mb: 100,
585            required_mb: 500
586        }
587        .is_retryable());
588        assert!(AppError::RateLimited {
589            detail: "429".into()
590        }
591        .is_retryable());
592        assert!(AppError::Timeout {
593            operation: "op".into(),
594            duration_secs: 30
595        }
596        .is_retryable());
597    }
598
599    #[test]
600    fn is_retryable_permanent_errors() {
601        assert!(!AppError::Validation("x".into()).is_retryable());
602        assert!(!AppError::NotFound("x".into()).is_retryable());
603        assert!(!AppError::Duplicate("x".into()).is_retryable());
604        assert!(!AppError::Conflict("x".into()).is_retryable());
605        assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
606    }
607
608    #[test]
609    fn exit_code_new_variants() {
610        assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
611        assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
612        assert_eq!(
613            AppError::Timeout {
614                operation: "x".into(),
615                duration_secs: 5
616            }
617            .exit_code(),
618            1
619        );
620    }
621
622    #[test]
623    fn app_error_size_does_not_exceed_budget() {
624        let size = std::mem::size_of::<AppError>();
625        assert!(
626            size <= 128,
627            "AppError is {size} bytes — exceeds 128-byte budget; \
628             consider boxing large variants to reduce memcpy cost in Result propagation"
629        );
630    }
631}