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#[derive(Error, Debug)]
17pub enum AppError {
18    /// Input failed schema, length or format validation. Maps to exit code `1`.
19    #[error("validation error: {0}")]
20    Validation(String),
21
22    /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `2`.
23    #[error("duplicate detected: {0}")]
24    Duplicate(String),
25
26    /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
27    #[error("conflict: {0}")]
28    Conflict(String),
29
30    /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
31    #[error("not found: {0}")]
32    NotFound(String),
33
34    /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
35    #[error("namespace not resolved: {0}")]
36    NamespaceError(String),
37
38    /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
39    #[error("limit exceeded: {0}")]
40    LimitExceeded(String),
41
42    /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
43    #[error("database error: {0}")]
44    Database(#[from] rusqlite::Error),
45
46    /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
47    #[error("embedding error: {0}")]
48    Embedding(String),
49
50    /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
51    #[error("sqlite-vec extension failed: {0}")]
52    VecExtension(String),
53
54    /// 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).
55    #[error("database busy: {0}")]
56    DbBusy(String),
57
58    /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
59    ///
60    /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
61    /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
62    #[error("batch partial failure: {failed} of {total} items failed")]
63    BatchPartialFailure { total: usize, failed: usize },
64
65    /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
66    #[error("IO error: {0}")]
67    Io(#[from] std::io::Error),
68
69    /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
70    #[error("internal error: {0}")]
71    Internal(#[from] anyhow::Error),
72
73    /// JSON serialization or deserialization failure. Maps to exit code `20`.
74    #[error("json error: {0}")]
75    Json(#[from] serde_json::Error),
76
77    /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
78    ///
79    /// Use `--allow-parallel` to skip the lock or `--wait-lock SECONDS` to retry.
80    #[error("lock busy: {0}")]
81    LockBusy(String),
82
83    /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
84    ///
85    /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
86    /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
87    #[error(
88        "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
89         use --max-concurrency or wait for other invocations to finish"
90    )]
91    AllSlotsFull { max: usize, waited_secs: u64 },
92
93    /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
94    ///
95    /// Returned when `sysinfo` reports available memory below
96    /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
97    #[error(
98        "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
99         to load the model; abort other loads or use --skip-memory-guard (exit 77)"
100    )]
101    LowMemory { available_mb: u64, required_mb: u64 },
102}
103
104impl AppError {
105    /// Returns the deterministic process exit code for this error variant.
106    ///
107    /// The codes follow the contract documented in the README: `1` for
108    /// validation, `2` for duplicates, `3` for conflicts, `4` for missing
109    /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
110    /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
111    /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
112    /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
113    /// slots are exhausted, and `77` when available memory is insufficient to
114    /// load the embedding model.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use sqlite_graphrag::errors::AppError;
120    ///
121    /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
122    /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 2);
123    /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
124    /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
125    /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
126    /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
127    /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
128    /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
129    /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
130    /// ```
131    pub fn exit_code(&self) -> i32 {
132        match self {
133            Self::Validation(_) => 1,
134            Self::Duplicate(_) => 2,
135            Self::Conflict(_) => 3,
136            Self::NotFound(_) => 4,
137            Self::NamespaceError(_) => 5,
138            Self::LimitExceeded(_) => 6,
139            Self::Database(_) => 10,
140            Self::Embedding(_) => 11,
141            Self::VecExtension(_) => 12,
142            Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
143            Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
144            Self::Io(_) => 14,
145            Self::Internal(_) => 20,
146            Self::Json(_) => 20,
147            Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
148            Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
149            Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
150        }
151    }
152
153    /// Returns the localized error message in the active language (`--lang` / `SQLITE_GRAPHRAG_LANG`).
154    ///
155    /// In English the text is identical to the `Display` generated by thiserror.
156    /// In Portuguese the prefixes and messages are translated to PT-BR.
157    pub fn localized_message(&self) -> String {
158        self.localized_message_for(current())
159    }
160
161    /// Returns the localized message for the explicitly provided language.
162    /// Useful in tests that cannot depend on the global `OnceLock`.
163    ///
164    /// # Examples
165    ///
166    /// ```
167    /// use sqlite_graphrag::errors::AppError;
168    /// use sqlite_graphrag::i18n::Language;
169    ///
170    /// let err = AppError::NotFound("mem-xyz".into());
171    ///
172    /// let en = err.localized_message_for(Language::English);
173    /// assert!(en.contains("not found"));
174    ///
175    /// let pt = err.localized_message_for(Language::Portuguese);
176    /// assert!(pt.contains("not found"));
177    /// ```
178    pub fn localized_message_for(&self, lang: Language) -> String {
179        match lang {
180            Language::English => self.to_string(),
181            Language::Portuguese => self.to_string_pt(),
182        }
183    }
184
185    fn to_string_pt(&self) -> String {
186        use crate::i18n::validation::app_error_pt as pt;
187        match self {
188            Self::Validation(msg) => pt::validation(msg),
189            Self::Duplicate(msg) => pt::duplicate(msg),
190            Self::Conflict(msg) => pt::conflict(msg),
191            Self::NotFound(msg) => pt::not_found(msg),
192            Self::NamespaceError(msg) => pt::namespace_error(msg),
193            Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
194            Self::Database(e) => pt::database(&e.to_string()),
195            Self::Embedding(msg) => pt::embedding(msg),
196            Self::VecExtension(msg) => pt::vec_extension(msg),
197            Self::DbBusy(msg) => pt::db_busy(msg),
198            Self::BatchPartialFailure { total, failed } => {
199                pt::batch_partial_failure(*total, *failed)
200            }
201            Self::Io(e) => pt::io(&e.to_string()),
202            Self::Internal(e) => pt::internal(&e.to_string()),
203            Self::Json(e) => pt::json(&e.to_string()),
204            Self::LockBusy(msg) => pt::lock_busy(msg),
205            Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
206            Self::LowMemory {
207                available_mb,
208                required_mb,
209            } => pt::low_memory(*available_mb, *required_mb),
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use std::io;
218
219    #[test]
220    fn exit_code_validation_returns_1() {
221        assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
222    }
223
224    #[test]
225    fn exit_code_duplicate_returns_2() {
226        assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 2);
227    }
228
229    #[test]
230    fn exit_code_conflict_returns_3() {
231        assert_eq!(
232            AppError::Conflict("updated_at changed".into()).exit_code(),
233            3
234        );
235    }
236
237    #[test]
238    fn exit_code_not_found_returns_4() {
239        assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
240    }
241
242    #[test]
243    fn exit_code_namespace_error_returns_5() {
244        assert_eq!(
245            AppError::NamespaceError("not resolved".into()).exit_code(),
246            5
247        );
248    }
249
250    #[test]
251    fn exit_code_limit_exceeded_returns_6() {
252        assert_eq!(
253            AppError::LimitExceeded("body too large".into()).exit_code(),
254            6
255        );
256    }
257
258    #[test]
259    fn exit_code_embedding_returns_11() {
260        assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
261    }
262
263    #[test]
264    fn exit_code_vec_extension_returns_12() {
265        assert_eq!(
266            AppError::VecExtension("extension did not load".into()).exit_code(),
267            12
268        );
269    }
270
271    #[test]
272    fn exit_code_db_busy_returns_15() {
273        assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
274    }
275
276    #[test]
277    fn exit_code_batch_partial_failure_returns_13() {
278        assert_eq!(
279            AppError::BatchPartialFailure {
280                total: 10,
281                failed: 3
282            }
283            .exit_code(),
284            13
285        );
286    }
287
288    #[test]
289    fn display_batch_partial_failure_includes_counts() {
290        let err = AppError::BatchPartialFailure {
291            total: 50,
292            failed: 7,
293        };
294        let msg = err.to_string();
295        assert!(msg.contains("7"));
296        assert!(msg.contains("50"));
297        // to_string() uses the English #[error] attr; PT is in localized_message_for
298        assert!(msg.contains("batch partial failure"));
299    }
300
301    #[test]
302    fn exit_code_io_returns_14() {
303        let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
304        assert_eq!(AppError::Io(io_err).exit_code(), 14);
305    }
306
307    #[test]
308    fn exit_code_internal_returns_20() {
309        let anyhow_err = anyhow::anyhow!("unexpected internal error");
310        assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
311    }
312
313    #[test]
314    fn exit_code_json_returns_20() {
315        let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
316        assert_eq!(AppError::Json(json_err).exit_code(), 20);
317    }
318
319    #[test]
320    fn exit_code_lock_busy_returns_75() {
321        assert_eq!(
322            AppError::LockBusy("another active instance".into()).exit_code(),
323            75
324        );
325    }
326
327    #[test]
328    fn display_validation_includes_message() {
329        let err = AppError::Validation("invalid id".into());
330        assert!(err.to_string().contains("invalid id"));
331        assert!(err.to_string().contains("validation error"));
332    }
333
334    #[test]
335    fn display_duplicate_includes_message() {
336        let err = AppError::Duplicate("proj/mem".into());
337        assert!(err.to_string().contains("proj/mem"));
338        assert!(err.to_string().contains("duplicate detected"));
339    }
340
341    #[test]
342    fn display_not_found_includes_message() {
343        let err = AppError::NotFound("id 42".into());
344        assert!(err.to_string().contains("id 42"));
345        assert!(err.to_string().contains("not found"));
346    }
347
348    #[test]
349    fn display_embedding_includes_message() {
350        let err = AppError::Embedding("wrong dimension".into());
351        assert!(err.to_string().contains("wrong dimension"));
352        assert!(err.to_string().contains("embedding error"));
353    }
354
355    #[test]
356    fn display_lock_busy_includes_message() {
357        let err = AppError::LockBusy("pid 1234".into());
358        assert!(err.to_string().contains("pid 1234"));
359        assert!(err.to_string().contains("lock busy"));
360    }
361
362    #[test]
363    fn from_io_error_converts_correctly() {
364        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
365        let app_err: AppError = io_err.into();
366        assert_eq!(app_err.exit_code(), 14);
367        assert!(app_err.to_string().contains("IO error"));
368    }
369
370    #[test]
371    fn from_anyhow_error_converts_correctly() {
372        let anyhow_err = anyhow::anyhow!("internal detail");
373        let app_err: AppError = anyhow_err.into();
374        assert_eq!(app_err.exit_code(), 20);
375        assert!(app_err.to_string().contains("internal error"));
376    }
377
378    #[test]
379    fn from_serde_json_error_converts_correctly() {
380        let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
381        let app_err: AppError = json_err.into();
382        assert_eq!(app_err.exit_code(), 20);
383        assert!(app_err.to_string().contains("json error"));
384    }
385
386    #[test]
387    fn exit_code_lock_busy_matches_constant() {
388        assert_eq!(
389            AppError::LockBusy("test".into()).exit_code(),
390            crate::constants::CLI_LOCK_EXIT_CODE
391        );
392    }
393
394    #[test]
395    fn localized_message_en_equals_to_string() {
396        let err = AppError::NotFound("mem-x".into());
397        assert_eq!(
398            err.localized_message_for(crate::i18n::Language::English),
399            err.to_string()
400        );
401    }
402
403    // Detailed Portuguese-specific assertions live in `src/i18n.rs`
404    // (the bilingual module). Here we only verify that delegation is wired
405    // correctly, without embedding PT strings in this English-only file.
406
407    #[test]
408    fn localized_message_pt_differs_from_en() {
409        let err = AppError::NotFound("mem-x".into());
410        let en = err.localized_message_for(crate::i18n::Language::English);
411        let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
412        assert_ne!(en, pt, "PT and EN must produce distinct messages");
413        assert!(pt.contains("mem-x"), "PT must include the variant payload");
414    }
415
416    #[test]
417    fn localized_message_pt_delegates_to_app_error_pt_helper() {
418        use crate::i18n::validation::app_error_pt as pt;
419
420        let cases: Vec<(AppError, String)> = vec![
421            (AppError::Validation("x".into()), pt::validation("x")),
422            (AppError::Duplicate("x".into()), pt::duplicate("x")),
423            (AppError::Conflict("x".into()), pt::conflict("x")),
424            (AppError::NotFound("x".into()), pt::not_found("x")),
425            (
426                AppError::NamespaceError("x".into()),
427                pt::namespace_error("x"),
428            ),
429            (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
430            (AppError::Embedding("x".into()), pt::embedding("x")),
431            (AppError::VecExtension("x".into()), pt::vec_extension("x")),
432            (AppError::DbBusy("x".into()), pt::db_busy("x")),
433            (
434                AppError::BatchPartialFailure {
435                    total: 10,
436                    failed: 3,
437                },
438                pt::batch_partial_failure(10, 3),
439            ),
440            (AppError::LockBusy("x".into()), pt::lock_busy("x")),
441            (
442                AppError::AllSlotsFull {
443                    max: 4,
444                    waited_secs: 60,
445                },
446                pt::all_slots_full(4, 60),
447            ),
448            (
449                AppError::LowMemory {
450                    available_mb: 100,
451                    required_mb: 500,
452                },
453                pt::low_memory(100, 500),
454            ),
455        ];
456
457        for (err, expected) in cases {
458            let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
459            assert_eq!(actual, expected, "delegation mismatch");
460        }
461    }
462}