Skip to main content

khive_storage/
error.rs

1//! Storage error types shared across all backend implementations.
2
3use std::borrow::Cow;
4use std::error::Error as StdError;
5use std::fmt;
6
7use thiserror::Error;
8
9use crate::blob::ContentRef;
10use crate::capability::StorageCapability;
11
12/// What is known about one request when its single-writer execution seam
13/// terminates or is retired.
14///
15/// The state is deliberately about the request, not about why the task
16/// stopped. Callers need this distinction to decide whether a write is known
17/// not to have started, known to have had its SQLite transaction rolled back,
18/// or may already have produced side effects.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum WriterTaskRequestState {
21    /// The request's operation closure was never invoked.
22    NotStarted,
23    /// The request ran inside `BEGIN IMMEDIATE`, and that transaction was
24    /// successfully rolled back on the connection that owned it. The request
25    /// may have returned an error, failed COMMIT, or panicked; none of its
26    /// wrapped SQLite writes committed.
27    TransactionRolledBack,
28    /// The request was accepted, but its exact outcome cannot be established;
29    /// it may already have produced side effects and must not be blindly
30    /// retried.
31    SideEffectsUnknown,
32}
33
34impl fmt::Display for WriterTaskRequestState {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(match self {
37            Self::NotStarted => "not_started",
38            Self::TransactionRolledBack => "transaction_rolled_back",
39            Self::SideEffectsUnknown => "side_effects_unknown",
40        })
41    }
42}
43
44/// Unified error type for all storage operations.
45#[derive(Debug, Error)]
46pub enum StorageError {
47    #[error("{capability:?} resource not found: {resource} ({key})")]
48    NotFound {
49        capability: StorageCapability,
50        resource: &'static str,
51        key: String,
52    },
53
54    #[error("{capability:?} resource already exists: {resource} ({key})")]
55    AlreadyExists {
56        capability: StorageCapability,
57        resource: &'static str,
58        key: String,
59    },
60
61    #[error("conflict in {capability:?} during {operation}: {message}")]
62    Conflict {
63        capability: StorageCapability,
64        operation: Cow<'static, str>,
65        message: String,
66    },
67
68    #[error("invalid input for {capability:?} during {operation}: {message}")]
69    InvalidInput {
70        capability: StorageCapability,
71        operation: Cow<'static, str>,
72        message: String,
73    },
74
75    #[error("unsupported operation for {capability:?}: {operation} ({message})")]
76    Unsupported {
77        capability: StorageCapability,
78        operation: Cow<'static, str>,
79        message: String,
80    },
81
82    /// The authoritative object is larger than the caller's declared
83    /// whole-buffer limit. `observed_at_least` is either opened-object
84    /// metadata used for early refusal or the running byte count that first
85    /// crossed the limit; metadata is not treated as the actual-byte bound.
86    #[error(
87        "blob {content_ref} exceeds the {max_bytes}-byte read limit (observed at least {observed_at_least} bytes)"
88    )]
89    BlobTooLarge {
90        content_ref: ContentRef,
91        max_bytes: u64,
92        observed_at_least: u64,
93    },
94
95    /// The complete bounded body disagreed with metadata obtained from the
96    /// same opened object / GET response.
97    #[error(
98        "blob {content_ref} metadata reports {metadata_bytes} bytes but the complete body contains {actual_bytes} bytes"
99    )]
100    BlobSizeMismatch {
101        content_ref: ContentRef,
102        metadata_bytes: u64,
103        actual_bytes: u64,
104    },
105
106    /// The complete, size-consistent bounded body did not hash to the
107    /// requested content-addressed reference.
108    #[error("blob digest mismatch: expected {expected}, computed {actual}")]
109    BlobDigestMismatch {
110        expected: ContentRef,
111        actual: ContentRef,
112    },
113
114    #[error("pool failure during {operation}: {message}")]
115    Pool {
116        operation: Cow<'static, str>,
117        message: String,
118    },
119
120    #[error("timeout during {operation}")]
121    Timeout { operation: Cow<'static, str> },
122
123    /// A bounded wait for storage admission (a reader/writer handle slot or a
124    /// pooled reader checkout) elapsed before anything was acquired. The
125    /// operation never started, so retrying cannot duplicate a side effect —
126    /// distinct from [`StorageError::Timeout`], which makes no claim about
127    /// whether work was in flight when the deadline expired.
128    #[error("admission timeout during {operation} after {timeout_ms}ms{pool}", pool = match .pool_identity {
129        Some(identity) => format!(" (pool: {identity})"),
130        None => String::new(),
131    })]
132    AdmissionTimeout {
133        operation: Cow<'static, str>,
134        /// The configured admission deadline that elapsed, in milliseconds.
135        timeout_ms: u64,
136        /// Canonical database path display, or `:memory:`, when the refusing pool is known.
137        pool_identity: Option<String>,
138    },
139
140    #[error("sql transaction failure during {operation}: {message}")]
141    Transaction {
142        operation: Cow<'static, str>,
143        message: String,
144    },
145
146    /// A cached read-only handle's admitted transaction pinned a WAL
147    /// snapshot past the configured `read_tx_max_age` bound and was
148    /// proactively rolled back so the next call can open a fresh snapshot
149    /// (#1846). Distinct from the generic [`StorageError::Transaction`]
150    /// variant — which also covers failed-cleanup and write-side ambiguity
151    /// cases that are not uniformly safe to retry — so callers (and MCP
152    /// dispatch) can recognize this specific, always-safe-to-retry
153    /// condition by variant rather than by parsing rendered text.
154    #[error(
155        "cached read-only transaction exceeded the maximum read-transaction age \
156         ({max_age_secs}s) during {operation} and was rolled back; retry to open a fresh \
157         read snapshot"
158    )]
159    ReadTransactionAgeEvicted {
160        operation: Cow<'static, str>,
161        max_age_secs: u64,
162    },
163
164    /// A cached read-only handle's admitted transaction pinned a WAL
165    /// snapshot past `read_tx_max_age`, and the proactive rollback used to
166    /// end the eviction (#1846) did not restore autocommit, or the rollback
167    /// itself failed. The connection is discarded either way rather than
168    /// returned to the pool. The age check that triggered this still ran
169    /// before any read on the connection, so — exactly like
170    /// [`StorageError::ReadTransactionAgeEvicted`] — retrying the caller's
171    /// operation on a fresh connection is always safe; this variant exists
172    /// only to keep that guarantee distinguishable from a clean eviction in
173    /// the rendered message and to keep [`StorageError::Transaction`] (whose
174    /// other cases are not uniformly safe to retry) out of this path.
175    #[error(
176        "cached read-only transaction exceeded the maximum read-transaction age \
177         ({max_age_secs}s) during {operation} but could not be cleanly rolled back \
178         ({message}); the connection was discarded, retry to open a fresh read snapshot"
179    )]
180    ReadTransactionAgeEvictionCleanupFailed {
181        operation: Cow<'static, str>,
182        max_age_secs: u64,
183        message: String,
184    },
185
186    #[error("serialization failure in {capability:?}: {message}")]
187    Serialization {
188        capability: StorageCapability,
189        message: String,
190    },
191
192    #[error("index maintenance failure in {capability:?}: {message}")]
193    IndexMaintenance {
194        capability: StorageCapability,
195        message: String,
196    },
197
198    #[error("backend driver error in {capability:?} during {operation}: {source}")]
199    Driver {
200        capability: StorageCapability,
201        operation: Cow<'static, str>,
202        #[source]
203        source: Box<dyn StdError + Send + Sync>,
204    },
205
206    /// The bounded write-queue channel (ADR-067 Component A) did not free
207    /// capacity within the caller-supplied deadline. Returned only when a
208    /// caller wraps `WriterTaskHandle::send`'s `channel.send().await` in a
209    /// `tokio::time::timeout`; there is no immediate-error `try_send` path.
210    #[error("write queue full: timed out after {timeout_ms}ms waiting for writer task capacity")]
211    WriteQueueFull { timeout_ms: u64 },
212
213    /// SQLite refused the writer task's `BEGIN IMMEDIATE` with
214    /// `SQLITE_BUSY`/`SQLITE_LOCKED` until the configured busy timeout. The
215    /// queue accepted the request, but its operation closure was never
216    /// invoked, so retrying that one failed operation is safe.
217    #[error(
218        "writer task could not begin within {timeout_ms}ms because SQLite remained busy; request was not executed"
219    )]
220    WriterTaskBusy { timeout_ms: u64 },
221
222    /// One transaction-wrapped writer request returned an error, and the
223    /// writer then proved that request's SQLite transaction was rolled back.
224    /// Unlike [`StorageError::WriterTaskTerminated`], this does not retire the
225    /// writer seam. `source` preserves the operation or COMMIT error so its
226    /// capability and retry policy remain independently inspectable.
227    #[error("writer task request failed (request_state={request_state}): {source}")]
228    WriterTaskRequestFailed {
229        request_state: WriterTaskRequestState,
230        #[source]
231        source: Box<StorageError>,
232    },
233
234    /// A single-writer execution seam has terminated permanently. This is the
235    /// historical writer-task variant and display name; the fail-closed
236    /// pool-mutex fallback also uses it when transaction finalization becomes
237    /// terminal. The state identifies what is known about this request at
238    /// that boundary. Retrying on the same pool cannot recover the retired
239    /// writer seam.
240    #[error("writer task terminated (request_state={request_state})")]
241    WriterTaskTerminated {
242        request_state: WriterTaskRequestState,
243    },
244
245    /// An internal storage failure not attributable to a specific storage
246    /// capability.
247    #[error("internal storage error: {0}")]
248    Internal(String),
249
250    /// `KHIVE_WRITE_QUEUE=1` is set but the calling thread has no Tokio
251    /// runtime context, so the writer task cannot be spawned (ADR-067
252    /// Component A). Returned instead of panicking.
253    /// See `crates/khive-storage/docs/api/error-taxonomy.md#writertasknoruntime`.
254    #[error(
255        "KHIVE_WRITE_QUEUE=1 but no Tokio runtime context is available to spawn the writer task"
256    )]
257    WriterTaskNoRuntime,
258
259    /// A filesystem-backed capability (e.g. `BlobStore`) refused a write
260    /// because `volume`'s available space, after accounting for the pending
261    /// write, would drop below the configured free-space floor (khive#292).
262    #[error(
263        "refusing write on {capability:?} at {volume}: {available_bytes} bytes available, \
264         below the {floor_bytes}-byte floor"
265    )]
266    CapacityFloor {
267        capability: StorageCapability,
268        volume: String,
269        available_bytes: u64,
270        floor_bytes: u64,
271    },
272}
273
274impl StorageError {
275    /// Construct a `Driver` error wrapping a backend-specific error source.
276    pub fn driver(
277        capability: StorageCapability,
278        operation: impl Into<Cow<'static, str>>,
279        source: impl StdError + Send + Sync + 'static,
280    ) -> Self {
281        Self::Driver {
282            capability,
283            operation: operation.into(),
284            source: Box::new(source),
285        }
286    }
287
288    /// Return the storage capability surface that produced this error, if any.
289    pub fn capability(&self) -> Option<StorageCapability> {
290        match self {
291            Self::NotFound { capability, .. }
292            | Self::AlreadyExists { capability, .. }
293            | Self::Conflict { capability, .. }
294            | Self::InvalidInput { capability, .. }
295            | Self::Unsupported { capability, .. }
296            | Self::Serialization { capability, .. }
297            | Self::IndexMaintenance { capability, .. }
298            | Self::Driver { capability, .. }
299            | Self::CapacityFloor { capability, .. } => Some(*capability),
300            Self::BlobTooLarge { .. }
301            | Self::BlobSizeMismatch { .. }
302            | Self::BlobDigestMismatch { .. } => Some(StorageCapability::Blob),
303            Self::WriterTaskRequestFailed { source, .. } => source.capability(),
304            Self::Pool { .. }
305            | Self::Timeout { .. }
306            | Self::AdmissionTimeout { .. }
307            | Self::Transaction { .. }
308            | Self::ReadTransactionAgeEvicted { .. }
309            | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
310            | Self::WriteQueueFull { .. }
311            | Self::WriterTaskBusy { .. }
312            | Self::WriterTaskTerminated { .. }
313            | Self::Internal(..)
314            | Self::WriterTaskNoRuntime => None,
315        }
316    }
317
318    /// Whether this error is transient and the operation may succeed on retry.
319    pub fn is_retryable(&self) -> bool {
320        if let Self::WriterTaskRequestFailed { source, .. } = self {
321            return source.is_retryable();
322        }
323        matches!(
324            self,
325            Self::Pool { .. }
326                | Self::Timeout { .. }
327                | Self::AdmissionTimeout { .. }
328                | Self::Transaction { .. }
329                | Self::ReadTransactionAgeEvicted { .. }
330                | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
331                | Self::WriteQueueFull { .. }
332                | Self::WriterTaskBusy { .. }
333        )
334    }
335
336    /// Whether this error is an FTS5 query-parser rejection of the MATCH
337    /// expression itself, as opposed to a connection/pool/driver-level
338    /// failure of the text-search backend.
339    ///
340    /// True only for `Driver` errors from the `Text` capability at the
341    /// `fts_search` operation whose message names one of SQLite's FTS5
342    /// parser failure modes (syntax error, stack overflow, unsupported
343    /// column/phrase/NEAR query); all other errors return `false`.
344    ///
345    /// Callers that fail-open the FTS leg of a hybrid search (degrading to
346    /// vector-only results on a bad query string) MUST gate on this
347    /// predicate rather than on `StorageError` broadly — treating every
348    /// `Err` as degradable turns a real backend outage into a silently-empty
349    /// "successful" search (issue #389).
350    /// See `crates/khive-storage/docs/api/error-taxonomy.md#is_fts5_syntax_error`.
351    pub fn is_fts5_syntax_error(&self) -> bool {
352        if let Self::WriterTaskRequestFailed { source, .. } = self {
353            return source.is_fts5_syntax_error();
354        }
355        let Self::Driver {
356            capability,
357            operation,
358            source,
359        } = self
360        else {
361            return false;
362        };
363        if *capability != StorageCapability::Text || operation.as_ref() != "fts_search" {
364            return false;
365        }
366        let msg = source.to_string();
367        msg.contains("fts5: syntax error")
368            || msg.contains("fts5: parser stack overflow")
369            || msg.contains("fts5: column queries are not supported")
370            || msg.contains("fts5: phrase queries are not supported (detail")
371            || msg.contains("fts5: NEAR queries are not supported (detail")
372    }
373
374    /// Whether this error is a UNIQUE constraint violation from a raw SQL
375    /// `execute` (e.g. an `INSERT` racing an existing row under a natural
376    /// key). True only for `Driver` errors from the `Sql` capability whose
377    /// `operation` is one of `execute`, `pool_writer.execute`, or
378    /// `tx.execute`, and whose message contains `UNIQUE constraint failed`.
379    /// Batch/script operations are intentionally excluded.
380    ///
381    /// Callers that treat exact-key duplicates as a tolerated no-op
382    /// (ADR-081 §4 serve-ledger idempotency) MUST gate on this predicate
383    /// rather than swallowing every `Driver` error at `execute` — that would
384    /// also hide genuine write failures (disk full, corruption).
385    /// See `crates/khive-storage/docs/api/error-taxonomy.md#is_unique_constraint_violation`.
386    pub fn is_unique_constraint_violation(&self) -> bool {
387        if let Self::WriterTaskRequestFailed { source, .. } = self {
388            return source.is_unique_constraint_violation();
389        }
390        let Self::Driver {
391            capability,
392            operation,
393            source,
394        } = self
395        else {
396            return false;
397        };
398        if *capability != StorageCapability::Sql {
399            return false;
400        }
401        if !matches!(
402            operation.as_ref(),
403            "execute" | "pool_writer.execute" | "tx.execute"
404        ) {
405            return false;
406        }
407        source.to_string().contains("UNIQUE constraint failed")
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use std::fmt;
415
416    #[derive(Debug)]
417    struct FakeSource(String);
418
419    impl fmt::Display for FakeSource {
420        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421            write!(f, "{}", self.0)
422        }
423    }
424
425    impl StdError for FakeSource {}
426
427    fn driver_err(operation: &'static str, message: &str) -> StorageError {
428        StorageError::driver(
429            StorageCapability::Text,
430            operation,
431            FakeSource(message.into()),
432        )
433    }
434
435    #[test]
436    fn writer_task_request_state_display_is_stable() {
437        assert_eq!(
438            WriterTaskRequestState::NotStarted.to_string(),
439            "not_started"
440        );
441        assert_eq!(
442            WriterTaskRequestState::TransactionRolledBack.to_string(),
443            "transaction_rolled_back"
444        );
445        assert_eq!(
446            WriterTaskRequestState::SideEffectsUnknown.to_string(),
447            "side_effects_unknown"
448        );
449    }
450
451    #[test]
452    fn writer_task_busy_is_retryable_without_claiming_queue_rejection() {
453        let error = StorageError::WriterTaskBusy { timeout_ms: 175 };
454        assert!(error.is_retryable());
455        assert_eq!(
456            error.to_string(),
457            "writer task could not begin within 175ms because SQLite remained busy; request was not executed"
458        );
459        assert_eq!(error.capability(), None);
460    }
461
462    #[test]
463    fn writer_task_request_failure_preserves_source_policy_and_rollback_state() {
464        let error = StorageError::WriterTaskRequestFailed {
465            request_state: WriterTaskRequestState::TransactionRolledBack,
466            source: Box::new(StorageError::Pool {
467                operation: "writer_task_commit".into(),
468                message: "commit refused".into(),
469            }),
470        };
471
472        assert_eq!(error.capability(), None);
473        assert!(
474            error.is_retryable(),
475            "rollback finality must not discard the source error's retry policy"
476        );
477        assert_eq!(
478            error.to_string(),
479            "writer task request failed (request_state=transaction_rolled_back): pool failure during writer_task_commit: commit refused"
480        );
481        assert_eq!(
482            StdError::source(&error).map(ToString::to_string),
483            Some("pool failure during writer_task_commit: commit refused".to_string()),
484            "the original typed storage error must remain the public source"
485        );
486    }
487
488    #[test]
489    fn writer_task_request_failure_does_not_invent_retryability() {
490        let error = StorageError::WriterTaskRequestFailed {
491            request_state: WriterTaskRequestState::TransactionRolledBack,
492            source: Box::new(StorageError::InvalidInput {
493                capability: StorageCapability::Notes,
494                operation: "append_note".into(),
495                message: "deterministic refusal".into(),
496            }),
497        };
498
499        assert!(!error.is_retryable());
500        assert_eq!(error.capability(), Some(StorageCapability::Notes));
501    }
502
503    #[test]
504    fn writer_task_terminated_is_uncapability_scoped_and_not_retryable() {
505        for request_state in [
506            WriterTaskRequestState::NotStarted,
507            WriterTaskRequestState::TransactionRolledBack,
508            WriterTaskRequestState::SideEffectsUnknown,
509        ] {
510            let error = StorageError::WriterTaskTerminated { request_state };
511            assert_eq!(error.capability(), None);
512            assert!(!error.is_retryable());
513            assert_eq!(
514                error.to_string(),
515                format!("writer task terminated (request_state={request_state})")
516            );
517        }
518    }
519
520    #[test]
521    fn blob_integrity_errors_are_blob_scoped_and_not_retryable() {
522        let requested = crate::blob::ContentRef::from_hex("a".repeat(64)).unwrap();
523        let actual = crate::blob::ContentRef::from_hex("b".repeat(64)).unwrap();
524        let errors = [
525            StorageError::BlobTooLarge {
526                content_ref: requested.clone(),
527                max_bytes: 8,
528                observed_at_least: 9,
529            },
530            StorageError::BlobSizeMismatch {
531                content_ref: requested.clone(),
532                metadata_bytes: 7,
533                actual_bytes: 8,
534            },
535            StorageError::BlobDigestMismatch {
536                expected: requested,
537                actual,
538            },
539        ];
540
541        for error in errors {
542            assert_eq!(error.capability(), Some(StorageCapability::Blob));
543            assert!(!error.is_retryable());
544        }
545    }
546
547    #[test]
548    fn fts5_syntax_error_at_fts_search_is_classified_as_syntax_error() {
549        let e = driver_err("fts_search", "fts5: syntax error near \"@\"");
550        assert!(e.is_fts5_syntax_error());
551    }
552
553    #[test]
554    fn fts5_parser_stack_overflow_is_classified_as_syntax_error() {
555        let e = driver_err("fts_search", "fts5: parser stack overflow");
556        assert!(e.is_fts5_syntax_error());
557    }
558
559    #[test]
560    fn fts5_unsupported_column_query_is_classified_as_syntax_error() {
561        let e = driver_err(
562            "fts_search",
563            "fts5: column queries are not supported (detail=none)",
564        );
565        assert!(e.is_fts5_syntax_error());
566    }
567
568    #[test]
569    fn timeout_is_not_classified_as_syntax_error() {
570        let e = StorageError::Timeout {
571            operation: "fts_search".into(),
572        };
573        assert!(!e.is_fts5_syntax_error());
574    }
575
576    #[test]
577    fn pool_failure_is_not_classified_as_syntax_error() {
578        let e = StorageError::Pool {
579            operation: "fts_search".into(),
580            message: "pool exhausted".into(),
581        };
582        assert!(!e.is_fts5_syntax_error());
583    }
584
585    #[test]
586    fn driver_error_at_non_search_operation_is_not_classified_as_syntax_error() {
587        let e = driver_err("open_fts_reader", "fts5: syntax error near \"@\"");
588        assert!(!e.is_fts5_syntax_error());
589    }
590
591    #[test]
592    fn driver_error_with_unrelated_message_is_not_classified_as_syntax_error() {
593        let e = driver_err("fts_search", "disk I/O error");
594        assert!(!e.is_fts5_syntax_error());
595    }
596
597    #[test]
598    fn fts5_phrase_detail_query_is_classified_as_syntax_error() {
599        let e = driver_err(
600            "fts_search",
601            "fts5: phrase queries are not supported (detail!=full)",
602        );
603        assert!(e.is_fts5_syntax_error());
604    }
605
606    #[test]
607    fn fts5_near_detail_query_is_classified_as_syntax_error() {
608        let e = driver_err(
609            "fts_search",
610            "fts5: NEAR queries are not supported (detail!=full)",
611        );
612        assert!(e.is_fts5_syntax_error());
613    }
614
615    #[test]
616    fn unprefixed_detail_message_is_not_classified_as_syntax_error() {
617        let e = driver_err(
618            "fts_search",
619            "phrase queries are not supported (detail!=full)",
620        );
621        assert!(!e.is_fts5_syntax_error());
622    }
623
624    #[test]
625    fn fts5_shadow_table_corruption_is_not_classified_as_syntax_error() {
626        let e = driver_err(
627            "fts_search",
628            "fts5: error creating shadow table notes_content: no such table",
629        );
630        assert!(!e.is_fts5_syntax_error());
631    }
632
633    #[test]
634    fn non_text_capability_is_not_classified_as_syntax_error() {
635        let e = StorageError::Driver {
636            capability: StorageCapability::Vectors,
637            operation: "fts_search".into(),
638            source: Box::new(FakeSource("fts5: syntax error near \"@\"".into())),
639        };
640        assert!(!e.is_fts5_syntax_error());
641    }
642
643    fn driver_err_sql(operation: &'static str, message: &str) -> StorageError {
644        StorageError::driver(
645            StorageCapability::Sql,
646            operation,
647            FakeSource(message.into()),
648        )
649    }
650
651    #[test]
652    fn unique_constraint_failure_at_execute_sql_capability_is_classified() {
653        let e = driver_err_sql(
654            "execute",
655            "UNIQUE constraint failed: brain_serve_ledger.namespace, \
656             brain_serve_ledger.target_id, brain_serve_ledger.query_class, \
657             brain_serve_ledger.served_at",
658        );
659        assert!(e.is_unique_constraint_violation());
660    }
661
662    #[test]
663    fn unique_constraint_failure_at_pool_writer_execute_is_classified() {
664        let e = driver_err_sql("pool_writer.execute", "UNIQUE constraint failed: t.id");
665        assert!(e.is_unique_constraint_violation());
666    }
667
668    #[test]
669    fn unique_constraint_message_at_non_execute_operation_is_not_classified() {
670        let e = driver_err_sql("query_row", "UNIQUE constraint failed: t.id");
671        assert!(!e.is_unique_constraint_violation());
672    }
673
674    #[test]
675    fn non_unique_driver_error_at_execute_is_not_classified() {
676        let e = driver_err_sql("execute", "disk I/O error");
677        assert!(!e.is_unique_constraint_violation());
678    }
679
680    #[test]
681    fn non_sql_capability_is_not_classified_as_unique_violation() {
682        let e = driver_err("execute", "UNIQUE constraint failed: t.id");
683        assert!(!e.is_unique_constraint_violation());
684    }
685
686    #[test]
687    fn timeout_is_not_classified_as_unique_violation() {
688        let e = StorageError::Timeout {
689            operation: "execute".into(),
690        };
691        assert!(!e.is_unique_constraint_violation());
692    }
693}