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 crate::spawn::preflight::PreFlightError;
10use thiserror::Error;
11
12/// Unified error type for all CLI and library operations.
13///
14/// Each variant corresponds to a distinct failure category. The
15/// [`AppError::exit_code`] method converts a variant into a stable numeric
16/// code so that shell callers and LLM agents can route on it.
17///
18/// # SemVer Policy
19///
20/// This enum is `#[non_exhaustive]`. New variants may be added in minor
21/// releases without breaking downstream match arms (use a wildcard `_`).
22#[derive(Error, Debug)]
23#[non_exhaustive]
24pub enum AppError {
25 /// Input failed schema, length or format validation. Maps to exit code `1`.
26 ///
27 /// This variant groups multiple validation failure causes. Callers that need
28 /// programmatic retry decisions should use [`AppError::is_retryable`] instead
29 /// of parsing the message string.
30 #[error("validation error: {0}")]
31 Validation(String),
32
33 /// External binary required for operation was not found in PATH. Maps to exit code `1`.
34 #[error("binary not found: {name} — ensure it is installed and in PATH")]
35 BinaryNotFound {
36 /// Name associated with this error.
37 name: String,
38 },
39
40 /// Remote service signaled rate limiting; caller should retry with backoff. Maps to exit code `1`.
41 #[error("rate limited: {detail}")]
42 RateLimited {
43 /// Human-readable detail message.
44 detail: String,
45 },
46
47 /// Operation exceeded its time budget. Maps to exit code `1`.
48 #[error("timeout after {duration_secs}s: {operation}")]
49 Timeout {
50 /// Operation.
51 operation: String,
52 /// Duration secs.
53 duration_secs: u64,
54 },
55
56 /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `9`.
57 #[error("duplicate detected: {0}")]
58 Duplicate(String),
59
60 /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
61 #[error("conflict: {0}")]
62 Conflict(String),
63
64 /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
65 #[error("not found: {0}")]
66 NotFound(String),
67
68 /// Memory lookup by `(namespace, name)` returned no row. Maps to exit code `4`.
69 ///
70 /// G55 S2 (v1.0.80): structural variant that carries the requested identifier
71 /// and namespace, eliminating the "not found: unknown in namespace 'X'" class
72 /// of bugs that masked which lookup target failed. The display format matches
73 /// the legacy string-based `NotFound` so the i18n replace-chain and external
74 /// scripts that pattern-match on `memory not found: name='N' in namespace 'NS'`
75 /// keep working.
76 #[error("memory not found: name='{name}' in namespace '{namespace}'")]
77 MemoryNotFound {
78 /// Name associated with this error.
79 name: String,
80 /// Namespace scope.
81 namespace: String,
82 },
83
84 /// Memory lookup by integer `id` returned no row. Maps to exit code `4`.
85 #[error("memory not found: id={id}")]
86 MemoryNotFoundById {
87 /// Numeric identifier.
88 id: i64,
89 },
90
91 /// GAP-SG-78: an entity referenced by a queued enrich item does not yet
92 /// exist in `entities`. Maps to exit code `4`.
93 ///
94 /// # Cause
95 ///
96 /// Distinct from the terminal [`Self::NotFound`] / [`Self::MemoryNotFound`]
97 /// cases (a memory that was deleted or renamed, permanently gone). An
98 /// entity can be referenced by a queue row BEFORE it is materialized: a
99 /// later enrich pass creates the entity, so its absence now is TRANSITORY,
100 /// not terminal. Collapsing both into a single `NotFound` string sent every
101 /// such item to the dead-letter on the first failure.
102 ///
103 /// # When it occurs
104 ///
105 /// Raised by the entity call-sites of `enrich` — `entity-descriptions`
106 /// (`call_entity_description`) and `entity-type-validate`
107 /// (`call_entity_type_validate`) — when the `(namespace, name)` lookup
108 /// returns no row. Classified as [`Self::is_retryable`] so the item is
109 /// rescheduled until `--max-attempts` is exhausted.
110 #[error("entity '{name}' not yet materialized in namespace '{namespace}'")]
111 EntityNotYetMaterialized {
112 /// Name associated with this error.
113 name: String,
114 /// Namespace scope.
115 namespace: String,
116 },
117
118 /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
119 #[error("namespace not resolved: {0}")]
120 NamespaceError(String),
121
122 /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
123 ///
124 /// v1.1.1 (P11): kept for caps other than the body-bytes and chunk-count
125 /// ceilings, which now have the typed [`Self::BodyTooLarge`] and
126 /// [`Self::TooManyChunks`] variants so the operator can tell WHICH cap
127 /// fired without parsing the message.
128 #[error("limit exceeded: {0}")]
129 LimitExceeded(String),
130
131 /// Body payload exceeded [`crate::constants::MAX_MEMORY_BODY_LEN`] bytes.
132 /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
133 ///
134 /// v1.1.1 (P11): the two independent write ceilings — body bytes and chunk
135 /// count — used to collapse into the generic `LimitExceeded` string, so an
136 /// operator hitting exit 6 could not tell WHICH cap fired. This variant
137 /// carries the measured size and the cap, and the message names the
138 /// constant, so both the stderr line and the JSON envelope identify the
139 /// ceiling deterministically (never by substring matching).
140 #[error(
141 "limit exceeded: body is {bytes} bytes, above the {limit}-byte cap \
142 (MAX_MEMORY_BODY_LEN); split the content into multiple memories"
143 )]
144 /// Body too large.
145 BodyTooLarge {
146 /// Observed size in bytes.
147 bytes: u64,
148 /// Configured limit.
149 limit: u64,
150 },
151
152 /// Chunking produced more chunks than
153 /// [`crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS`]. Maps to exit
154 /// code `6` (same contract as [`Self::LimitExceeded`]).
155 ///
156 /// v1.1.1 (P11): counterpart of [`Self::BodyTooLarge`] for the chunk-count
157 /// ceiling. Carries the measured chunk count and the cap so the operator
158 /// can distinguish a chunk overflow from a byte overflow on exit 6.
159 #[error(
160 "limit exceeded: document produces {chunks} chunks, above the \
161 {limit}-chunk cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS); split the \
162 document before writing"
163 )]
164 /// Too many chunks.
165 TooManyChunks {
166 /// Observed chunk count.
167 chunks: usize,
168 /// Configured limit.
169 limit: usize,
170 },
171
172 /// Body exceeded [`crate::constants::EMBEDDING_REQUEST_MAX_TOKENS`] tokens
173 /// (conservative cl100k proxy for the `qwen/qwen3-embedding-8b` window).
174 /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
175 ///
176 /// v1.1.2 (Gap 2): third typed payload ceiling alongside
177 /// [`Self::BodyTooLarge`] (bytes) and [`Self::TooManyChunks`] (chunks).
178 /// The token cap used to surface as a generic `Validation` (exit 1) deep
179 /// inside the REST embedding client; it now fires at the write-command
180 /// boundary with the estimated token count and the cap, so the operator
181 /// can tell WHICH ceiling fired without substring matching (GAP-SG-73).
182 #[error(
183 "limit exceeded: body is {tokens} tokens (estimated), above the \
184 {limit}-token cap (EMBEDDING_REQUEST_MAX_TOKENS); split the content \
185 into multiple memories"
186 )]
187 /// Too many tokens.
188 TooManyTokens {
189 /// Observed token count.
190 tokens: u64,
191 /// Configured limit.
192 limit: u64,
193 },
194
195 /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
196 #[error("database error: {0}")]
197 Database(#[from] rusqlite::Error),
198
199 /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
200 #[error("embedding error: {0}")]
201 Embedding(String),
202
203 /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
204 #[error("sqlite-vec extension failed: {0}")]
205 VecExtension(String),
206
207 /// 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).
208 #[error("database busy: {0}")]
209 DbBusy(String),
210
211 /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
212 ///
213 /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
214 /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
215 #[error("batch partial failure: {failed} of {total} items failed")]
216 BatchPartialFailure {
217 /// Total items processed.
218 total: usize,
219 /// Number of failed items.
220 failed: usize,
221 },
222
223 /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
224 #[error("IO error: {0}")]
225 Io(#[from] std::io::Error),
226
227 /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
228 #[error(transparent)]
229 Internal(#[from] anyhow::Error),
230
231 /// JSON serialization or deserialization failure. Maps to exit code `20`.
232 #[error("json error: {0}")]
233 Json(#[from] serde_json::Error),
234
235 /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
236 ///
237 /// Use `--allow-parallel` to skip the lock or `--wait-lock SECONDS` to retry.
238 #[error("lock busy: {0}")]
239 LockBusy(String),
240
241 /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
242 ///
243 /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
244 /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
245 #[error(
246 "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
247 use --max-concurrency or wait for other invocations to finish"
248 )]
249 /// All slots full.
250 AllSlotsFull {
251 /// Maximum allowed value.
252 max: usize,
253 /// Seconds spent waiting.
254 waited_secs: u64,
255 },
256
257 /// A heavy long-running job is already running for this job_type/namespace
258 /// pair. Maps to exit code `75` (the same `EX_TEMPFAIL` code used by the
259 /// CLI semaphore).
260 ///
261 /// G28-B (v1.0.68): ensures at most one `enrich`, `ingest --mode
262 /// claude-code`, or `ingest --mode codex` runs at a time per namespace.
263 /// Use `--wait-job-singleton <SECONDS>` (per-command) to poll until the
264 /// other invocation finishes.
265 #[error(
266 "job {job_type} for namespace '{namespace}' is already running (exit 75); \
267 wait for it to finish or pass --wait-job-singleton <SECONDS>"
268 )]
269 /// Job singleton locked.
270 JobSingletonLocked {
271 /// Job type identifier.
272 job_type: String,
273 /// Namespace scope.
274 namespace: String,
275 },
276
277 /// G45: an LLM embedding operation is already running against the
278 /// same `(namespace, db)` pair in another process. Exit code 75
279 /// (retryable). The caller can pass `--wait-embed-singleton
280 /// <SECONDS>` to poll until the lock drops.
281 #[error(
282 "embedding singleton for namespace '{namespace}' is already held (exit 75); \
283 another CLI is calling the LLM on this database; pass --wait-embed-singleton <SECONDS> to wait"
284 )]
285 /// Embedding singleton locked.
286 EmbeddingSingletonLocked {
287 /// Namespace scope.
288 namespace: String,
289 },
290
291 /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
292 ///
293 /// Returned when `sysinfo` reports available memory below
294 /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
295 #[error(
296 "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
297 to load the model; abort other loads or use --skip-memory-guard (exit 77)"
298 )]
299 /// Low memory.
300 LowMemory {
301 /// Available memory in megabytes.
302 available_mb: u64,
303 /// Required memory in megabytes.
304 required_mb: u64,
305 },
306
307 /// v1.0.82 (GAP-002 final): shutdown was requested via SIGINT, SIGTERM or
308 /// SIGHUP before the current command completed. Maps to exit code
309 /// [`crate::constants::SHUTDOWN_EXIT_CODE`] (19).
310 ///
311 /// The signal name is preserved in the `signal` field so the JSON
312 /// envelope emitted before exit can route the operator to a
313 /// deterministic branch. Distinct from the legacy `128 + signal`
314 /// Unix convention (130/143/129) so LLM agents can match on a
315 /// single code for "cancelled by user".
316 #[error("shutdown signal received: {signal}")]
317 Shutdown {
318 /// Signal that triggered shutdown.
319 signal: String,
320 },
321
322 /// v1.0.87 (GAP-META-005, ADR-0045): pre-flight validation gate
323 /// rejected the spawn before fork. Maps to exit code `16`.
324 ///
325 /// The `source` field carries the structured [`PreFlightError`]
326 /// variant so callers and operators can route on the specific
327 /// failure class (BinaryNotFound, ArgvExceedsArgMax,
328 /// McpConfigInlineJsonRejected, McpConfigPathMissing,
329 /// McpConfigPathInvalidJson, WalkUpMcpJsonInvalid,
330 /// OutputBufferTooSmall, ClaudeConfigDirNotEmpty) instead of
331 /// parsing the legacy `detail: String` representation.
332 ///
333 /// This variant is **permanent** — retrying the same argv will fail
334 /// identically. Operators must fix the underlying condition (install
335 /// the binary, shorten the body, override `CLAUDE_CONFIG_DIR`,
336 /// substitute the inline `--mcp-config '{}'` for a tempfile path,
337 /// etc.) before retrying.
338 #[error("preflight validation failed: {source}")]
339 PreFlightFailed {
340 /// Underlying preflight error.
341 source: Box<PreFlightError>,
342 },
343
344 /// v1.0.97 (GAP-SG-01/03): the OpenRouter provider returned a structured
345 /// error object (an `error` field carrying `code` and `message`), often
346 /// inside an HTTP 200 body (e.g. token/context-length overflow). Maps to
347 /// exit code `1`.
348 ///
349 /// Modelling the provider rejection as a typed variant — instead of the
350 /// generic `Embedding`/`Validation` string — stops the optimistic success
351 /// parse from masking the cause with a misleading missing-field error. The
352 /// `code` and `message` carry the REAL provider diagnostics.
353 ///
354 /// This variant is **permanent**: a structured provider error in a success
355 /// body is a content or configuration rejection that retrying the identical
356 /// request will not fix. Genuine rate limiting surfaces as HTTP 429 and is
357 /// retried inside the HTTP client (then exposed via `RateLimited` when
358 /// attempts are exhausted), so it never reaches callers as `ProviderError`.
359 #[error("provider error (code {code}): {message}")]
360 ProviderError {
361 /// Provider error code.
362 code: String,
363 /// Provider error message.
364 message: String,
365 },
366}
367
368/// Bridges the structured [`PreFlightError`] produced by the
369/// pre-flight validation gate (v1.0.87, ADR-0045) into the unified
370/// [`AppError`] envelope. Lets spawners use the `?` operator instead
371/// of hand-rolling `AppError::PreFlightFailed { source: ... }` at every
372/// call site, and keeps the variant alive as the canonical exit code 16
373/// path rather than the dead code it was at v1.0.87.
374impl From<PreFlightError> for AppError {
375 fn from(source: PreFlightError) -> Self {
376 AppError::PreFlightFailed {
377 source: Box::new(source),
378 }
379 }
380}
381
382impl AppError {
383 /// Returns the deterministic process exit code for this error variant.
384 ///
385 /// The codes follow the contract documented in the README: `1` for
386 /// validation, `9` for duplicates (moved from `2` in v1.0.52), `3` for conflicts, `4` for missing
387 /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
388 /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
389 /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
390 /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
391 /// slots are exhausted, and `77` when available memory is insufficient to
392 /// load the embedding model.
393 ///
394 /// # Examples
395 ///
396 /// ```
397 /// use sqlite_graphrag::errors::AppError;
398 ///
399 /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
400 /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 9);
401 /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
402 /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
403 /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
404 /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
405 /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
406 /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
407 /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
408 /// ```
409 #[inline]
410 #[must_use]
411 pub fn exit_code(&self) -> i32 {
412 match self {
413 Self::Validation(_) => 1,
414 Self::BinaryNotFound { .. } => 1,
415 Self::RateLimited { .. } => 1,
416 Self::Timeout { .. } => 1,
417 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
418 Self::Conflict(_) => 3,
419 Self::NotFound(_) => 4,
420 Self::MemoryNotFound { .. } => 4,
421 Self::MemoryNotFoundById { .. } => 4,
422 Self::EntityNotYetMaterialized { .. } => 4,
423 Self::NamespaceError(_) => 5,
424 Self::LimitExceeded(_) => 6,
425 Self::BodyTooLarge { .. } => 6,
426 Self::TooManyChunks { .. } => 6,
427 Self::TooManyTokens { .. } => 6,
428 Self::Database(_) => 10,
429 Self::Embedding(_) => 11,
430 Self::VecExtension(_) => 12,
431 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
432 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
433 Self::Io(_) => 14,
434 Self::Internal(_) => 20,
435 Self::Json(_) => 20,
436 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
437 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
438 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
439 Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
440 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
441 Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
442 Self::PreFlightFailed { .. } => 16,
443 Self::ProviderError { .. } => 1,
444 }
445 }
446
447 /// Returns `true` when the error is transient and the operation may
448 /// succeed on retry with backoff.
449 ///
450 /// # Examples
451 ///
452 /// ```
453 /// use sqlite_graphrag::errors::AppError;
454 ///
455 /// assert!(AppError::DbBusy("busy".into()).is_retryable());
456 /// assert!(AppError::LockBusy("held".into()).is_retryable());
457 /// assert!(!AppError::NotFound("x".into()).is_retryable());
458 /// assert!(!AppError::Validation("bad".into()).is_retryable());
459 /// ```
460 #[inline]
461 #[must_use]
462 pub fn is_retryable(&self) -> bool {
463 matches!(
464 self,
465 Self::DbBusy(_)
466 | Self::LockBusy(_)
467 | Self::AllSlotsFull { .. }
468 | Self::JobSingletonLocked { .. }
469 | Self::EmbeddingSingletonLocked { .. }
470 | Self::LowMemory { .. }
471 | Self::RateLimited { .. }
472 | Self::Timeout { .. }
473 | Self::EntityNotYetMaterialized { .. }
474 )
475 }
476
477 /// Returns `true` when shutdown was requested by the user via signal.
478 ///
479 /// Distinct from `is_permanent` because shutdown is a USER intent, not
480 /// a state to retry against. The operation should be retried with
481 /// `--resume` (GAP-001) when the persisted staging row still exists.
482 ///
483 /// # Examples
484 ///
485 /// ```
486 /// use sqlite_graphrag::errors::AppError;
487 ///
488 /// assert!(AppError::Shutdown { signal: "SIGINT".into() }.is_shutdown());
489 /// assert!(!AppError::Validation("x".into()).is_shutdown());
490 /// ```
491 #[inline]
492 #[must_use]
493 pub fn is_shutdown(&self) -> bool {
494 matches!(self, Self::Shutdown { .. })
495 }
496
497 /// Returns `true` when the error is permanent and must NOT be retried.
498 ///
499 /// Complement to [`Self::is_retryable`]. Errors not classified by either
500 /// method (e.g. `Database`, `Io`, `Internal`) are ambiguous — the caller
501 /// decides based on context.
502 ///
503 /// # Examples
504 ///
505 /// ```
506 /// use sqlite_graphrag::errors::AppError;
507 ///
508 /// assert!(AppError::Validation("bad".into()).is_permanent());
509 /// assert!(!AppError::DbBusy("busy".into()).is_permanent());
510 /// ```
511 #[inline]
512 #[must_use]
513 pub fn is_permanent(&self) -> bool {
514 matches!(
515 self,
516 Self::Validation(_)
517 | Self::BinaryNotFound { .. }
518 | Self::Duplicate(_)
519 | Self::NotFound(_)
520 | Self::MemoryNotFound { .. }
521 | Self::MemoryNotFoundById { .. }
522 | Self::NamespaceError(_)
523 | Self::LimitExceeded(_)
524 | Self::BodyTooLarge { .. }
525 | Self::TooManyChunks { .. }
526 | Self::TooManyTokens { .. }
527 | Self::VecExtension(_)
528 | Self::PreFlightFailed { .. }
529 | Self::ProviderError { .. }
530 )
531 }
532
533 /// GAP-SG-39: returns an actionable remediation hint for the error, surfaced
534 /// in the stdout error envelope as the `suggestion` field. The hint tells the
535 /// operator HOW to recover instead of leaving an exit code without guidance —
536 /// this is what makes a write rejection (e.g. a malformed name) observable and
537 /// fixable. Returns `None` for variants whose own message is already
538 /// self-remediating.
539 #[must_use]
540 pub fn suggestion(&self) -> Option<&'static str> {
541 match self {
542 Self::Validation(_) => Some(
543 "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
544 ),
545 Self::Duplicate(_) => {
546 Some("pass --force-merge to update the existing memory instead of failing")
547 }
548 Self::Conflict(_) => Some(
549 "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
550 ),
551 Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => {
552 Some("verify the name/id and namespace with `list --json` or `read --name <n> --json`")
553 }
554 Self::NamespaceError(_) => {
555 // GAP-SG-103: product env is not read (G-T-XDG-04). Point operators
556 // at the real channels: CLI flag and XDG `namespace.default`.
557 Some("set --namespace or `config set namespace.default <name>`; inspect with `namespace-detect --json`")
558 }
559 Self::LimitExceeded(_) => {
560 Some("split the input into smaller memories or raise the documented cap before retrying")
561 }
562 Self::BodyTooLarge { .. } => {
563 Some("the body-bytes cap (MAX_MEMORY_BODY_LEN) fired; split the content into multiple memories or use --body-file")
564 }
565 Self::TooManyChunks { .. } => {
566 Some("the chunk-count cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS) fired; split the document into smaller memories before writing")
567 }
568 Self::TooManyTokens { .. } => {
569 Some("the token cap (EMBEDDING_REQUEST_MAX_TOKENS) fired; split the content into multiple memories, keeping each under ~25000 tokens")
570 }
571 Self::Embedding(_) => Some(
572 "verify the embedding backend and OPENROUTER_API_KEY; re-run `enrich --operation re-embed` once resolved",
573 ),
574 Self::Database(_) | Self::DbBusy(_) => {
575 Some("run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy")
576 }
577 Self::Io(_) => Some("check the path exists and is writable, then retry"),
578 Self::RateLimited { .. } => {
579 Some("wait for the reported retry-after window, then retry")
580 }
581 Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => {
582 Some("wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton")
583 }
584 _ => None,
585 }
586 }
587
588 /// Returns the localized error message in the active language (`--lang` / XDG `i18n.lang`).
589 ///
590 /// In English the text is identical to the `Display` generated by thiserror.
591 /// In Portuguese the prefixes and messages are translated to PT-BR.
592 pub fn localized_message(&self) -> String {
593 self.localized_message_for(current())
594 }
595
596 /// Returns the localized message for the explicitly provided language.
597 /// Useful in tests that cannot depend on the global `OnceLock`.
598 ///
599 /// # Examples
600 ///
601 /// ```
602 /// use sqlite_graphrag::errors::AppError;
603 /// use sqlite_graphrag::i18n::Language;
604 ///
605 /// let err = AppError::NotFound("mem-xyz".into());
606 ///
607 /// let en = err.localized_message_for(Language::English);
608 /// assert!(en.contains("not found"));
609 ///
610 /// let pt = err.localized_message_for(Language::Portuguese);
611 /// assert!(pt.contains("n\u{e3}o encontrado"));
612 /// ```
613 pub fn localized_message_for(&self, lang: Language) -> String {
614 match lang {
615 Language::English => self.to_string(),
616 Language::Portuguese => self.to_string_pt(),
617 }
618 }
619
620 fn to_string_pt(&self) -> String {
621 use crate::i18n::validation::app_error_pt as pt;
622 match self {
623 Self::Validation(msg) => pt::validation(msg),
624 Self::BinaryNotFound { name } => pt::binary_not_found(name),
625 Self::RateLimited { detail } => pt::rate_limited(detail),
626 Self::Timeout {
627 operation,
628 duration_secs,
629 } => pt::timeout(operation, *duration_secs),
630 Self::Duplicate(msg) => pt::duplicate(msg),
631 Self::Conflict(msg) => pt::conflict(msg),
632 Self::NotFound(msg) => pt::not_found(msg),
633 Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
634 Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
635 Self::EntityNotYetMaterialized { name, namespace } => {
636 pt::entity_not_yet_materialized(name, namespace)
637 }
638 Self::NamespaceError(msg) => pt::namespace_error(msg),
639 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
640 Self::BodyTooLarge { bytes, limit } => pt::body_too_large(*bytes, *limit),
641 Self::TooManyChunks { chunks, limit } => pt::too_many_chunks(*chunks, *limit),
642 Self::TooManyTokens { tokens, limit } => pt::too_many_tokens(*tokens, *limit),
643 Self::Database(e) => pt::database(&e.to_string()),
644 Self::Embedding(msg) => pt::embedding(msg),
645 Self::VecExtension(msg) => pt::vec_extension(msg),
646 Self::DbBusy(msg) => pt::db_busy(msg),
647 Self::BatchPartialFailure { total, failed } => {
648 pt::batch_partial_failure(*total, *failed)
649 }
650 Self::Io(e) => pt::io(&e.to_string()),
651 Self::Internal(e) => pt::internal(&e.to_string()),
652 Self::Json(e) => pt::json(&e.to_string()),
653 Self::LockBusy(msg) => pt::lock_busy(msg),
654 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
655 Self::JobSingletonLocked {
656 job_type,
657 namespace,
658 } => pt::job_singleton_locked(job_type, namespace),
659 Self::EmbeddingSingletonLocked { namespace } => {
660 pt::embedding_singleton_locked(namespace)
661 }
662 Self::LowMemory {
663 available_mb,
664 required_mb,
665 } => pt::low_memory(*available_mb, *required_mb),
666 Self::Shutdown { signal } => pt::shutdown(signal),
667 Self::PreFlightFailed { source } => pt::preflight_failed(&source.to_string()),
668 Self::ProviderError { code, message } => pt::provider_error(code, message),
669 }
670 }
671}
672#[cfg(test)]
673#[path = "errors_tests.rs"]
674mod tests;