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 /// The argv parsed but asks for something impossible. Maps to exit code `2`.
33 ///
34 /// Distinct from [`AppError::Validation`] on purpose. `Validation` means the
35 /// DATA the caller supplied is wrong and exits `1`; this means the REQUEST
36 /// itself is incoherent and exits [`crate::constants::USAGE_EXIT_CODE`], the
37 /// same code clap returns for a rejected flag. An agent therefore branches on
38 /// one number for "fix the command line" whether the parser or the
39 /// agent-native surface caught it.
40 ///
41 /// Introduced in v1.2.6 for the refusals the surface can only make once it
42 /// has the envelope: a projection or predicate key that exists in no result
43 /// element, a predicate aimed at a collection the caller never named, and a
44 /// filter evaluated over a page the query had already truncated.
45 ///
46 /// Carries the flags the request declared and the binary could not honour.
47 /// `rules-rust-cli-stdin-stdout-silent-discard` requires the error JSON to
48 /// name them in a field: leaving them inside the prose would force the
49 /// caller to parse a sentence — in one of two languages — to learn which of
50 /// its own arguments were dropped. Empty for refusals that discard nothing,
51 /// such as a predicate judged over a truncated page.
52 #[error("usage error: {message}")]
53 Usage {
54 /// The localized refusal, already carrying its corrective action.
55 message: String,
56 /// Flags the caller passed that this invocation could not apply.
57 discarded_flags: Vec<String>,
58 },
59
60 /// External binary required for operation was not found in PATH. Maps to exit code `1`.
61 #[error("binary not found: {name} — ensure it is installed and in PATH")]
62 BinaryNotFound {
63 /// Name associated with this error.
64 name: String,
65 },
66
67 /// Remote service signaled rate limiting; caller should retry with backoff. Maps to exit code `1`.
68 #[error("rate limited: {detail}")]
69 RateLimited {
70 /// Human-readable detail message.
71 detail: String,
72 },
73
74 /// Operation exceeded its time budget. Maps to exit code `1`.
75 #[error("timeout after {duration_secs}s: {operation}")]
76 Timeout {
77 /// Operation.
78 operation: String,
79 /// Duration secs.
80 duration_secs: u64,
81 },
82
83 /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `9`.
84 #[error("duplicate detected: {0}")]
85 Duplicate(String),
86
87 /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
88 #[error("conflict: {0}")]
89 Conflict(String),
90
91 /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
92 #[error("not found: {0}")]
93 NotFound(String),
94
95 /// Memory lookup by `(namespace, name)` returned no row. Maps to exit code `4`.
96 ///
97 /// G55 S2 (v1.0.80): structural variant that carries the requested identifier
98 /// and namespace, eliminating the "not found: unknown in namespace 'X'" class
99 /// of bugs that masked which lookup target failed. The display format matches
100 /// the legacy string-based `NotFound` so the i18n replace-chain and external
101 /// scripts that pattern-match on `memory not found: name='N' in namespace 'NS'`
102 /// keep working.
103 #[error("memory not found: name='{name}' in namespace '{namespace}'")]
104 MemoryNotFound {
105 /// Name associated with this error.
106 name: String,
107 /// Namespace scope.
108 namespace: String,
109 },
110
111 /// Memory lookup by integer `id` returned no row. Maps to exit code `4`.
112 #[error("memory not found: id={id}")]
113 MemoryNotFoundById {
114 /// Numeric identifier.
115 id: i64,
116 },
117
118 /// GAP-SG-78: an entity referenced by a queued enrich item does not yet
119 /// exist in `entities`. Maps to exit code `4`.
120 ///
121 /// # Cause
122 ///
123 /// Distinct from the terminal [`Self::NotFound`] / [`Self::MemoryNotFound`]
124 /// cases (a memory that was deleted or renamed, permanently gone). An
125 /// entity can be referenced by a queue row BEFORE it is materialized: a
126 /// later enrich pass creates the entity, so its absence now is TRANSITORY,
127 /// not terminal. Collapsing both into a single `NotFound` string sent every
128 /// such item to the dead-letter on the first failure.
129 ///
130 /// # When it occurs
131 ///
132 /// Raised by the entity call-sites of `enrich` — `entity-descriptions`
133 /// (`call_entity_description`) and `entity-type-validate`
134 /// (`call_entity_type_validate`) — when the `(namespace, name)` lookup
135 /// returns no row. Classified as [`Self::is_retryable`] so the item is
136 /// rescheduled until `--max-attempts` is exhausted.
137 #[error("entity '{name}' not yet materialized in namespace '{namespace}'")]
138 EntityNotYetMaterialized {
139 /// Name associated with this error.
140 name: String,
141 /// Namespace scope.
142 namespace: String,
143 },
144
145 /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
146 #[error("namespace not resolved: {0}")]
147 NamespaceError(String),
148
149 /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
150 ///
151 /// v1.1.1 (P11): kept for caps other than the body-bytes and chunk-count
152 /// ceilings, which now have the typed [`Self::BodyTooLarge`] and
153 /// [`Self::TooManyChunks`] variants so the operator can tell WHICH cap
154 /// fired without parsing the message.
155 #[error("limit exceeded: {0}")]
156 LimitExceeded(String),
157
158 /// Body payload exceeded [`crate::constants::MAX_MEMORY_BODY_LEN`] bytes.
159 /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
160 ///
161 /// v1.1.1 (P11): the two independent write ceilings — body bytes and chunk
162 /// count — used to collapse into the generic `LimitExceeded` string, so an
163 /// operator hitting exit 6 could not tell WHICH cap fired. This variant
164 /// carries the measured size and the cap, and the message names the
165 /// constant, so both the stderr line and the JSON envelope identify the
166 /// ceiling deterministically (never by substring matching).
167 #[error(
168 "limit exceeded: body is {bytes} bytes, above the {limit}-byte cap \
169 (MAX_MEMORY_BODY_LEN); split the content into multiple memories"
170 )]
171 /// Body too large.
172 BodyTooLarge {
173 /// Observed size in bytes.
174 bytes: u64,
175 /// Configured limit.
176 limit: u64,
177 },
178
179 /// Chunking produced more chunks than
180 /// [`crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS`]. Maps to exit
181 /// code `6` (same contract as [`Self::LimitExceeded`]).
182 ///
183 /// v1.1.1 (P11): counterpart of [`Self::BodyTooLarge`] for the chunk-count
184 /// ceiling. Carries the measured chunk count and the cap so the operator
185 /// can distinguish a chunk overflow from a byte overflow on exit 6.
186 #[error(
187 "limit exceeded: document produces {chunks} chunks, above the \
188 {limit}-chunk cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS); split the \
189 document before writing"
190 )]
191 /// Too many chunks.
192 TooManyChunks {
193 /// Observed chunk count.
194 chunks: usize,
195 /// Configured limit.
196 limit: usize,
197 },
198
199 /// Body exceeded [`crate::constants::EMBEDDING_REQUEST_MAX_TOKENS`] tokens
200 /// (conservative cl100k proxy for the `qwen/qwen3-embedding-8b` window).
201 /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
202 ///
203 /// v1.1.2 (Gap 2): third typed payload ceiling alongside
204 /// [`Self::BodyTooLarge`] (bytes) and [`Self::TooManyChunks`] (chunks).
205 /// The token cap used to surface as a generic `Validation` (exit 1) deep
206 /// inside the REST embedding client; it now fires at the write-command
207 /// boundary with the estimated token count and the cap, so the operator
208 /// can tell WHICH ceiling fired without substring matching (GAP-SG-73).
209 #[error(
210 "limit exceeded: body is {tokens} tokens (estimated), above the \
211 {limit}-token cap (EMBEDDING_REQUEST_MAX_TOKENS); split the content \
212 into multiple memories"
213 )]
214 /// Too many tokens.
215 TooManyTokens {
216 /// Observed token count.
217 tokens: u64,
218 /// Configured limit.
219 limit: u64,
220 },
221
222 /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
223 #[error("database error: {0}")]
224 Database(#[from] rusqlite::Error),
225
226 /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
227 #[error("embedding error: {0}")]
228 Embedding(String),
229
230 /// GAP-SG-270: an embedding failure that still CARRIES the retry verdict
231 /// `EmbedError` computed at its origin (exact HTTP status / provider code).
232 /// Exit code `11` and `Display` text are identical to [`Self::Embedding`],
233 /// so no operator-facing contract changes; only the enrich queue reads the
234 /// extra field, via `queue_ops::classify_enrich_outcome`. Without it every
235 /// embedding failure landed in the untyped [`Self::Embedding`] bucket and a
236 /// PERMANENT one burned every `--max-attempts` retry. Deliberately absent
237 /// from [`Self::is_retryable`], which never covered [`Self::Embedding`].
238 #[error("embedding error: {message}")]
239 EmbeddingClassified {
240 /// Failure detail, identical to the payload of [`Self::Embedding`].
241 message: String,
242 /// Retry verdict computed where the failure originated (HTTP status /
243 /// provider code), never inferred from `message`.
244 retry_class: crate::retry::AttemptOutcome,
245 },
246
247 /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
248 #[error("sqlite-vec extension failed: {0}")]
249 VecExtension(String),
250
251 /// 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).
252 #[error("database busy: {0}")]
253 DbBusy(String),
254
255 /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
256 ///
257 /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
258 /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
259 #[error("batch partial failure: {failed} of {total} items failed")]
260 BatchPartialFailure {
261 /// Total items processed.
262 total: usize,
263 /// Number of failed items.
264 failed: usize,
265 },
266
267 /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
268 #[error("IO error: {0}")]
269 Io(#[from] std::io::Error),
270
271 /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
272 #[error(transparent)]
273 Internal(#[from] anyhow::Error),
274
275 /// JSON serialization or deserialization failure. Maps to exit code `20`.
276 #[error("json error: {0}")]
277 Json(#[from] serde_json::Error),
278
279 /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
280 ///
281 /// Use `--wait-lock <SECONDS>` to poll until the lock drops.
282 #[error("lock busy: {0}")]
283 LockBusy(String),
284
285 /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
286 ///
287 /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
288 /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
289 #[error(
290 "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
291 use --max-concurrency or wait for other invocations to finish"
292 )]
293 /// All slots full.
294 AllSlotsFull {
295 /// Maximum allowed value.
296 max: usize,
297 /// Seconds spent waiting.
298 waited_secs: u64,
299 },
300
301 /// A heavy long-running job is already running for this job_type/namespace
302 /// pair. Maps to exit code `75` (the same `EX_TEMPFAIL` code used by the
303 /// CLI semaphore).
304 ///
305 /// G28-B (v1.0.68): ensures at most one `enrich`
306 /// or `ingest` runs at a time per namespace. The guard predates v1.2.0 and
307 /// once also covered `ingest --mode claude-code` / `--mode codex`.
308 /// Use `--wait-job-singleton <SECONDS>` (per-command) to poll until the
309 /// other invocation finishes.
310 #[error(
311 "job {job_type} for namespace '{namespace}' is already running (exit 75); \
312 wait for it to finish or pass --wait-job-singleton <SECONDS>"
313 )]
314 /// Job singleton locked.
315 JobSingletonLocked {
316 /// Job type identifier.
317 job_type: String,
318 /// Namespace scope.
319 namespace: String,
320 },
321
322 /// G45: an LLM embedding operation is already running against the
323 /// same `(namespace, db)` pair in another process. Exit code 75
324 /// (retryable). The caller can pass `--wait-lock <SECONDS>` to poll until
325 /// the lock drops. Until v1.2.8 this message named the wait flag removed in
326 /// v1.2.0, so obeying the refusal returned exit 2 (GAP-SG-303).
327 #[error(
328 "embedding singleton for namespace '{namespace}' is already held (exit 75); \
329 another CLI is calling the LLM on this database; pass --wait-lock <SECONDS> to wait"
330 )]
331 /// Embedding singleton locked.
332 EmbeddingSingletonLocked {
333 /// Namespace scope.
334 namespace: String,
335 },
336
337 /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
338 ///
339 /// Returned when `sysinfo` reports available memory below
340 /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
341 #[error(
342 "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
343 to load the model; abort other loads or use --skip-memory-guard (exit 77)"
344 )]
345 /// Low memory.
346 LowMemory {
347 /// Available memory in megabytes.
348 available_mb: u64,
349 /// Required memory in megabytes.
350 required_mb: u64,
351 },
352
353 /// v1.0.82 (GAP-002 final): shutdown was requested via SIGINT, SIGTERM or
354 /// SIGHUP before the current command completed. Maps to exit code
355 /// [`crate::constants::SHUTDOWN_EXIT_CODE`] (19).
356 ///
357 /// The signal name is preserved in the `signal` field so the JSON
358 /// envelope emitted before exit can route the operator to a
359 /// deterministic branch. Distinct from the legacy `128 + signal`
360 /// Unix convention (130/143/129) so LLM agents can match on a
361 /// single code for "cancelled by user".
362 #[error("shutdown signal received: {signal}")]
363 Shutdown {
364 /// Signal that triggered shutdown.
365 signal: String,
366 },
367
368 /// v1.0.97 (GAP-SG-01/03): the OpenRouter provider returned a structured
369 /// error object (an `error` field carrying `code` and `message`), often
370 /// inside an HTTP 200 body (e.g. token/context-length overflow). Maps to
371 /// exit code `1`.
372 ///
373 /// Modelling the provider rejection as a typed variant — instead of the
374 /// generic `Embedding`/`Validation` string — stops the optimistic success
375 /// parse from masking the cause with a misleading missing-field error. The
376 /// `code` and `message` carry the REAL provider diagnostics.
377 ///
378 /// This variant is **permanent**: a structured provider error in a success
379 /// body is a content or configuration rejection that retrying the identical
380 /// request will not fix. Genuine rate limiting surfaces as HTTP 429 and is
381 /// retried inside the HTTP client (then exposed via `RateLimited` when
382 /// attempts are exhausted), so it never reaches callers as `ProviderError`.
383 #[error("provider error (code {code}): {message}")]
384 ProviderError {
385 /// Provider error code.
386 code: String,
387 /// Provider error message.
388 message: String,
389 },
390}
391
392impl AppError {
393 /// Returns the deterministic process exit code for this error variant.
394 ///
395 /// The codes follow the contract documented in the README: `1` for
396 /// validation, `9` for duplicates (moved from `2` in v1.0.52), `3` for conflicts, `4` for missing
397 /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
398 /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
399 /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
400 /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
401 /// slots are exhausted, and `77` when available memory is insufficient to
402 /// load the embedding model.
403 ///
404 /// # Examples
405 ///
406 /// ```
407 /// use sqlite_graphrag::errors::AppError;
408 ///
409 /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
410 /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 9);
411 /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
412 /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
413 /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
414 /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
415 /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
416 /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
417 /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
418 /// ```
419 #[inline]
420 #[must_use]
421 pub fn exit_code(&self) -> i32 {
422 match self {
423 Self::Validation(_) => 1,
424 Self::Usage { .. } => crate::constants::USAGE_EXIT_CODE,
425 Self::BinaryNotFound { .. } => 1,
426 Self::RateLimited { .. } => 1,
427 Self::Timeout { .. } => 1,
428 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
429 Self::Conflict(_) => 3,
430 Self::NotFound(_) => 4,
431 Self::MemoryNotFound { .. } => 4,
432 Self::MemoryNotFoundById { .. } => 4,
433 Self::EntityNotYetMaterialized { .. } => 4,
434 Self::NamespaceError(_) => 5,
435 Self::LimitExceeded(_) => 6,
436 Self::BodyTooLarge { .. } => 6,
437 Self::TooManyChunks { .. } => 6,
438 Self::TooManyTokens { .. } => 6,
439 Self::Database(_) => 10,
440 Self::Embedding(_) => 11,
441 Self::EmbeddingClassified { .. } => 11,
442 Self::VecExtension(_) => 12,
443 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
444 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
445 Self::Io(_) => 14,
446 Self::Internal(_) => 20,
447 Self::Json(_) => 20,
448 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
449 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
450 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
451 Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
452 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
453 Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
454 Self::ProviderError { .. } => 1,
455 }
456 }
457
458 /// Flags the caller passed that this invocation could not honour.
459 ///
460 /// Empty for every variant that discards nothing, so a consumer reads one
461 /// field instead of branching on the variant it happened to receive.
462 #[inline]
463 #[must_use]
464 pub fn discarded_flags(&self) -> &[String] {
465 match self {
466 Self::Usage {
467 discarded_flags, ..
468 } => discarded_flags,
469 _ => &[],
470 }
471 }
472
473 /// Returns `true` when the error is transient and the operation may
474 /// succeed on retry with backoff.
475 ///
476 /// # Examples
477 ///
478 /// ```
479 /// use sqlite_graphrag::errors::AppError;
480 ///
481 /// assert!(AppError::DbBusy("busy".into()).is_retryable());
482 /// assert!(AppError::LockBusy("held".into()).is_retryable());
483 /// assert!(!AppError::NotFound("x".into()).is_retryable());
484 /// assert!(!AppError::Validation("bad".into()).is_retryable());
485 /// ```
486 #[inline]
487 #[must_use]
488 pub fn is_retryable(&self) -> bool {
489 matches!(
490 self,
491 Self::DbBusy(_)
492 | Self::LockBusy(_)
493 | Self::AllSlotsFull { .. }
494 | Self::JobSingletonLocked { .. }
495 | Self::EmbeddingSingletonLocked { .. }
496 | Self::LowMemory { .. }
497 | Self::RateLimited { .. }
498 | Self::Timeout { .. }
499 | Self::EntityNotYetMaterialized { .. }
500 )
501 }
502
503 /// Returns `true` when shutdown was requested by the user via signal.
504 ///
505 /// Distinct from `is_permanent` because shutdown is a USER intent, not
506 /// a state to retry against. The operation should be retried with
507 /// `--resume` (GAP-001) when the persisted staging row still exists.
508 ///
509 /// # Examples
510 ///
511 /// ```
512 /// use sqlite_graphrag::errors::AppError;
513 ///
514 /// assert!(AppError::Shutdown { signal: "SIGINT".into() }.is_shutdown());
515 /// assert!(!AppError::Validation("x".into()).is_shutdown());
516 /// ```
517 #[inline]
518 #[must_use]
519 pub fn is_shutdown(&self) -> bool {
520 matches!(self, Self::Shutdown { .. })
521 }
522
523 /// Returns `true` when the error is permanent and must NOT be retried.
524 ///
525 /// Complement to [`Self::is_retryable`]. Errors not classified by either
526 /// method (e.g. `Database`, `Io`, `Internal`) are ambiguous — the caller
527 /// decides based on context.
528 ///
529 /// # Examples
530 ///
531 /// ```
532 /// use sqlite_graphrag::errors::AppError;
533 ///
534 /// assert!(AppError::Validation("bad".into()).is_permanent());
535 /// assert!(!AppError::DbBusy("busy".into()).is_permanent());
536 /// ```
537 #[inline]
538 #[must_use]
539 pub fn is_permanent(&self) -> bool {
540 matches!(
541 self,
542 Self::Validation(_)
543 | Self::Usage { .. }
544 | Self::BinaryNotFound { .. }
545 | Self::Duplicate(_)
546 | Self::NotFound(_)
547 | Self::MemoryNotFound { .. }
548 | Self::MemoryNotFoundById { .. }
549 | Self::NamespaceError(_)
550 | Self::LimitExceeded(_)
551 | Self::BodyTooLarge { .. }
552 | Self::TooManyChunks { .. }
553 | Self::TooManyTokens { .. }
554 | Self::VecExtension(_)
555 | Self::ProviderError { .. }
556 )
557 }
558
559 /// Stable retry classification carried by the stdout error envelope as
560 /// `error_class`.
561 ///
562 /// An agent that reads only `code` cannot tell a busy lock from a malformed
563 /// name, so it either retries what will never succeed or gives up on what
564 /// would. This field answers that question without the agent knowing a
565 /// single exit code by heart.
566 ///
567 /// The vocabulary matches the one the enrich queue already persists in
568 /// `queue.error_class`, plus `ambiguous` for the third state
569 /// [`Self::is_permanent`] documents: errors classified by neither predicate,
570 /// where the caller decides from context.
571 ///
572 /// # Examples
573 ///
574 /// ```
575 /// use sqlite_graphrag::errors::AppError;
576 ///
577 /// assert_eq!(AppError::DbBusy("busy".into()).error_class(), "transient");
578 /// assert_eq!(AppError::Validation("bad".into()).error_class(), "permanent");
579 /// assert_eq!(
580 /// AppError::Internal(anyhow::anyhow!("x")).error_class(),
581 /// "ambiguous"
582 /// );
583 /// ```
584 #[inline]
585 #[must_use]
586 pub fn error_class(&self) -> &'static str {
587 if self.is_retryable() {
588 "transient"
589 } else if self.is_permanent() {
590 "permanent"
591 } else {
592 "ambiguous"
593 }
594 }
595
596 /// GAP-SG-39: returns an actionable remediation hint for the error, surfaced
597 /// in the stdout error envelope as the `suggestion` field. The hint tells the
598 /// operator HOW to recover instead of leaving an exit code without guidance —
599 /// this is what makes a write rejection (e.g. a malformed name) observable and
600 /// fixable. Returns `None` for variants whose own message is already
601 /// self-remediating.
602 ///
603 /// Localized through [`Self::suggestion_for`], so a `pt-BR` operator never
604 /// receives a Portuguese `message` beside an English `suggestion` in the same
605 /// envelope.
606 #[must_use]
607 pub fn suggestion(&self) -> Option<&'static str> {
608 self.suggestion_for(current())
609 }
610
611 /// Returns the remediation hint in the explicitly provided language.
612 ///
613 /// Mirrors [`Self::localized_message_for`] so tests can assert both languages
614 /// without depending on the global `OnceLock`.
615 ///
616 /// # Examples
617 ///
618 /// ```
619 /// use sqlite_graphrag::errors::AppError;
620 /// use sqlite_graphrag::i18n::Language;
621 ///
622 /// let err = AppError::Duplicate("mem-xyz".into());
623 /// assert!(err.suggestion_for(Language::English).unwrap().contains("pass"));
624 /// assert!(err.suggestion_for(Language::Portuguese).unwrap().contains("passe"));
625 /// ```
626 #[must_use]
627 pub fn suggestion_for(&self, lang: Language) -> Option<&'static str> {
628 self.suggestion_pair().map(|(en, pt)| match lang {
629 Language::English => en,
630 Language::Portuguese => pt,
631 })
632 }
633
634 /// Both renderings of the hint, side by side.
635 ///
636 /// Keeping the pair in one arm is what makes a missing translation
637 /// impossible to introduce: adding a variant without its Portuguese text
638 /// does not compile.
639 fn suggestion_pair(&self) -> Option<(&'static str, &'static str)> {
640 match self {
641 Self::Validation(_) => Some((
642 "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
643 "revise a entrada contra o --help do comando; nomes devem ser kebab-case (minúsculas, dígitos, hifens) e corpos não podem ser vazios",
644 )),
645 Self::Usage { .. } => Some((
646 "the command line asks for something impossible; read `agent_surface.unresolved_keys` / `filter_scope` in the error envelope and correct the flag, or declare the narrower intent with --filter-scope page or --allow-unknown-keys",
647 "a linha de comando pede algo impossível; leia `agent_surface.unresolved_keys` / `filter_scope` no envelope de erro e corrija a flag, ou declare a intenção mais estreita com --filter-scope page ou --allow-unknown-keys",
648 )),
649 Self::Duplicate(_) => Some((
650 "pass --force-merge to update the existing memory instead of failing",
651 "passe --force-merge para atualizar a memória existente em vez de falhar",
652 )),
653 Self::Conflict(_) => Some((
654 "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
655 "outro escritor alterou a linha; releia com `read --name <n> --json` e repita com um --expected-updated-at novo",
656 )),
657 Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => Some((
658 "verify the name/id and namespace with `list --json` or `read --name <n> --json`",
659 "verifique o nome/id e o namespace com `list --json` ou `read --name <n> --json`",
660 )),
661 Self::NamespaceError(_) => Some((
662 // GAP-SG-103: product env is not read (G-T-XDG-04). Point operators
663 // at the real channels: CLI flag and XDG `namespace.default`.
664 "set --namespace or `config set namespace.default <name>`; inspect with `namespace-detect --json`",
665 "defina --namespace ou `config set namespace.default <nome>`; inspecione com `namespace-detect --json`",
666 )),
667 Self::LimitExceeded(_) => Some((
668 "split the input into smaller memories or raise the documented cap before retrying",
669 "divida a entrada em memórias menores ou eleve o teto documentado antes de repetir",
670 )),
671 Self::BodyTooLarge { .. } => Some((
672 "the body-bytes cap (MAX_MEMORY_BODY_LEN) fired; split the content into multiple memories or use --body-file",
673 "o teto de bytes do corpo (MAX_MEMORY_BODY_LEN) disparou; divida o conteúdo em várias memórias ou use --body-file",
674 )),
675 Self::TooManyChunks { .. } => Some((
676 "the chunk-count cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS) fired; split the document into smaller memories before writing",
677 "o teto de chunks (REMEMBER_MAX_SAFE_MULTI_CHUNKS) disparou; divida o documento em memórias menores antes de gravar",
678 )),
679 Self::TooManyTokens { .. } => Some((
680 "the token cap (EMBEDDING_REQUEST_MAX_TOKENS) fired; split the content into multiple memories, keeping each under ~25000 tokens",
681 "o teto de tokens (EMBEDDING_REQUEST_MAX_TOKENS) disparou; divida o conteúdo em várias memórias, cada uma abaixo de ~25000 tokens",
682 )),
683 Self::Embedding(_) | Self::EmbeddingClassified { .. } => Some((
684 // The product never reads an API key from the environment
685 // (G-T-XDG-04), so naming one here would send the operator down a
686 // channel that cannot work.
687 "store the key with `config add-key --provider openrouter --from-stdin` or pass --openrouter-api-key; re-run `enrich --operation re-embed` once resolved",
688 "grave a chave com `config add-key --provider openrouter --from-stdin` ou passe --openrouter-api-key; re-execute `enrich --operation re-embed` depois de resolver",
689 )),
690 Self::Database(_) | Self::DbBusy(_) => Some((
691 "run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy",
692 "rode `health --json` e depois `vacuum --json`; amplie --wait-lock se o banco estiver ocupado",
693 )),
694 Self::Io(_) => Some((
695 "check the path exists and is writable, then retry",
696 "confira se o caminho existe e é gravável, depois repita",
697 )),
698 Self::RateLimited { .. } => Some((
699 "wait for the reported retry-after window, then retry",
700 "aguarde a janela de retry-after informada, depois repita",
701 )),
702 Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => Some((
703 "wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton",
704 "aguarde a outra invocação terminar ou passe --wait-lock / --wait-job-singleton",
705 )),
706 _ => None,
707 }
708 }
709
710 /// Returns the localized error message in the active language (`--lang` / XDG `i18n.lang`).
711 ///
712 /// In English the text is identical to the `Display` generated by thiserror.
713 /// In Portuguese the prefixes and messages are translated to PT-BR.
714 pub fn localized_message(&self) -> String {
715 self.localized_message_for(current())
716 }
717
718 /// Returns the localized message for the explicitly provided language.
719 /// Useful in tests that cannot depend on the global `OnceLock`.
720 ///
721 /// # Examples
722 ///
723 /// ```
724 /// use sqlite_graphrag::errors::AppError;
725 /// use sqlite_graphrag::i18n::Language;
726 ///
727 /// let err = AppError::NotFound("mem-xyz".into());
728 ///
729 /// let en = err.localized_message_for(Language::English);
730 /// assert!(en.contains("not found"));
731 ///
732 /// let pt = err.localized_message_for(Language::Portuguese);
733 /// assert!(pt.contains("n\u{e3}o encontrado"));
734 /// ```
735 pub fn localized_message_for(&self, lang: Language) -> String {
736 match lang {
737 Language::English => self.to_string(),
738 Language::Portuguese => self.to_string_pt(),
739 }
740 }
741
742 fn to_string_pt(&self) -> String {
743 use crate::i18n::validation::app_error_pt as pt;
744 match self {
745 Self::Validation(msg) => pt::validation(msg),
746 Self::Usage { message, .. } => pt::usage(message),
747 Self::BinaryNotFound { name } => pt::binary_not_found(name),
748 Self::RateLimited { detail } => pt::rate_limited(detail),
749 Self::Timeout {
750 operation,
751 duration_secs,
752 } => pt::timeout(operation, *duration_secs),
753 Self::Duplicate(msg) => pt::duplicate(msg),
754 Self::Conflict(msg) => pt::conflict(msg),
755 Self::NotFound(msg) => pt::not_found(msg),
756 Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
757 Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
758 Self::EntityNotYetMaterialized { name, namespace } => {
759 pt::entity_not_yet_materialized(name, namespace)
760 }
761 Self::NamespaceError(msg) => pt::namespace_error(msg),
762 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
763 Self::BodyTooLarge { bytes, limit } => pt::body_too_large(*bytes, *limit),
764 Self::TooManyChunks { chunks, limit } => pt::too_many_chunks(*chunks, *limit),
765 Self::TooManyTokens { tokens, limit } => pt::too_many_tokens(*tokens, *limit),
766 Self::Database(e) => pt::database(&e.to_string()),
767 Self::Embedding(msg) => pt::embedding(msg),
768 // Same PT text as `Embedding`: the retry verdict is machine-facing
769 // and never shown to the operator.
770 Self::EmbeddingClassified { message, .. } => pt::embedding(message),
771 Self::VecExtension(msg) => pt::vec_extension(msg),
772 Self::DbBusy(msg) => pt::db_busy(msg),
773 Self::BatchPartialFailure { total, failed } => {
774 pt::batch_partial_failure(*total, *failed)
775 }
776 Self::Io(e) => pt::io(&e.to_string()),
777 Self::Internal(e) => pt::internal(&e.to_string()),
778 Self::Json(e) => pt::json(&e.to_string()),
779 Self::LockBusy(msg) => pt::lock_busy(msg),
780 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
781 Self::JobSingletonLocked {
782 job_type,
783 namespace,
784 } => pt::job_singleton_locked(job_type, namespace),
785 Self::EmbeddingSingletonLocked { namespace } => {
786 pt::embedding_singleton_locked(namespace)
787 }
788 Self::LowMemory {
789 available_mb,
790 required_mb,
791 } => pt::low_memory(*available_mb, *required_mb),
792 Self::Shutdown { signal } => pt::shutdown(signal),
793 Self::ProviderError { code, message } => pt::provider_error(code, message),
794 }
795 }
796}
797#[cfg(test)]
798#[path = "errors_tests.rs"]
799mod tests;