Skip to main content

turso_orm_driver/
error.rs

1//! The driver's error type and its classification, modeled by [`Error`].
2//!
3//! Callers rarely need the exact engine error; they need to know whether to
4//! retry, whether a constraint was hit and which one, or whether the
5//! connection is gone. [`ErrorKind`] answers those questions uniformly for
6//! engine errors and for the driver's own failures, so retry loops and
7//! upsert fallbacks can be written once.
8//!
9//! The classification is partly textual because `turso::Error` carries only
10//! a string for most variants: the variant gives the broad kind, and for
11//! constraint violations and MVCC write conflicts the message is inspected.
12//! This module owns that mapping; it does not retry or log anything itself.
13//!
14//! - [`Error`]: the enum returned by every fallible operation of the crate;
15//! - [`ErrorKind`] and [`ConstraintKind`]: the classification;
16//! - [`Result`]: the crate's result alias.
17
18use std::fmt;
19
20/// The crate's result alias.
21pub type Result<T, E = Error> = std::result::Result<T, E>;
22
23/// Which constraint a statement violated.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum ConstraintKind {
27    /// A `UNIQUE` or `PRIMARY KEY` constraint.
28    Unique,
29    /// A `FOREIGN KEY` constraint.
30    ForeignKey,
31    /// A `NOT NULL` constraint.
32    NotNull,
33    /// A `CHECK` constraint.
34    Check,
35    /// Any other constraint.
36    Other,
37}
38
39/// The classification of an [`Error`].
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum ErrorKind {
43    /// Lock contention, a stale snapshot or an MVCC commit conflict. Worth
44    /// retrying.
45    Busy,
46    /// A constraint violation.
47    Constraint(ConstraintKind),
48    /// The database could not be opened or a connection could not be
49    /// established.
50    Connection,
51    /// The pool timed out handing out a connection.
52    PoolTimeout,
53    /// A value could not be decoded into the requested Rust type.
54    Decode,
55    /// A value could not be encoded as a parameter.
56    Encode,
57    /// A query returned no row where one was required.
58    NotFound,
59    /// Misuse of the API, for example contradictory options or a closed
60    /// pool.
61    Misuse,
62    /// Any other engine error.
63    Other,
64}
65
66/// The crate's error type.
67#[derive(Debug, thiserror::Error)]
68#[non_exhaustive]
69pub enum Error {
70    /// An error reported by the Turso engine, already classified.
71    #[error("{kind:?}: {source}")]
72    Turso {
73        /// The classification.
74        kind: ErrorKind,
75        /// The engine error.
76        #[source]
77        source: turso::Error,
78    },
79    /// An error reported by the serverless client, already classified.
80    #[cfg(feature = "serverless")]
81    #[cfg_attr(docsrs, doc(cfg(feature = "serverless")))]
82    #[error("{kind:?}: {source}")]
83    Remote {
84        /// The classification.
85        kind: ErrorKind,
86        /// The client error.
87        #[source]
88        source: turso_serverless::Error,
89    },
90    /// No pooled connection became free within the acquire timeout.
91    #[error("timed out waiting for a pooled connection")]
92    PoolTimeout,
93    /// A column could not be decoded into the requested Rust type.
94    #[error("cannot decode column {column} as {ty}: {reason}")]
95    Decode {
96        /// The column name or index, as the caller asked for it.
97        column: String,
98        /// The requested Rust type.
99        ty: &'static str,
100        /// Why the conversion failed.
101        reason: String,
102    },
103    /// A value could not be encoded as a parameter.
104    #[error("cannot encode value: {0}")]
105    Encode(String),
106    /// A query returned no row where one was required.
107    #[error("no row returned")]
108    NotFound,
109    /// The connect options are contradictory or unsupported.
110    #[error("invalid connect options: {0}")]
111    InvalidOptions(String),
112    /// The API was used in a way it cannot honour.
113    #[error("misuse: {0}")]
114    Misuse(String),
115    /// A free-form error, for callers layering on top of this crate.
116    #[error("{0}")]
117    Custom(String),
118}
119
120impl Error {
121    /// Classifies the error.
122    pub fn kind(&self) -> ErrorKind {
123        match self {
124            Error::Turso { kind, .. } => *kind,
125            #[cfg(feature = "serverless")]
126            Error::Remote { kind, .. } => *kind,
127            Error::PoolTimeout => ErrorKind::PoolTimeout,
128            Error::Decode { .. } => ErrorKind::Decode,
129            Error::Encode(_) => ErrorKind::Encode,
130            Error::NotFound => ErrorKind::NotFound,
131            Error::InvalidOptions(_) | Error::Misuse(_) => ErrorKind::Misuse,
132            Error::Custom(_) => ErrorKind::Other,
133        }
134    }
135
136    /// Whether the error is transient lock contention worth retrying.
137    pub fn is_busy(&self) -> bool {
138        self.kind() == ErrorKind::Busy
139    }
140
141    /// The constraint kind, when this is a constraint violation.
142    pub fn constraint(&self) -> Option<ConstraintKind> {
143        match self.kind() {
144            ErrorKind::Constraint(k) => Some(k),
145            _ => None,
146        }
147    }
148
149    /// Builds a decoding error for `column` and the requested type.
150    ///
151    /// Public so that hand-written and derived [`FromValue`](crate::FromValue)
152    /// impls report failures in the same shape as the built-in decoders.
153    pub fn decode(column: impl fmt::Display, ty: &'static str, reason: impl fmt::Display) -> Self {
154        Error::Decode {
155            column: column.to_string(),
156            ty,
157            reason: reason.to_string(),
158        }
159    }
160}
161
162impl From<turso::Error> for Error {
163    fn from(source: turso::Error) -> Self {
164        let kind = classify(&source);
165        Error::Turso { kind, source }
166    }
167}
168
169/// Classifies an engine error by variant, falling back to its message.
170///
171/// MVCC conflicts are reported as a generic `Error`, so that case is
172/// matched textually by [`is_conflict`] to make it retryable like any
173/// other busy condition.
174fn classify(err: &turso::Error) -> ErrorKind {
175    match err {
176        turso::Error::Busy(_) | turso::Error::BusySnapshot(_) => ErrorKind::Busy,
177        turso::Error::Constraint(msg) => ErrorKind::Constraint(classify_constraint(msg)),
178        turso::Error::Misuse(_) => ErrorKind::Misuse,
179        turso::Error::Error(msg) if is_conflict(msg) => ErrorKind::Busy,
180        turso::Error::IoError(..) | turso::Error::NotAdb(_) | turso::Error::Corrupt(_) => {
181            ErrorKind::Connection
182        }
183        _ => ErrorKind::Other,
184    }
185}
186
187/// Classifies a serverless client error by variant, falling back to its
188/// message the same way as for the engine. HTTP failures count as
189/// connection errors: the statement never reached the database.
190#[cfg(feature = "serverless")]
191impl From<turso_serverless::Error> for Error {
192    fn from(source: turso_serverless::Error) -> Self {
193        use turso_serverless::Error as E;
194        let kind = match &source {
195            E::Busy(_) | E::BusySnapshot(_) => ErrorKind::Busy,
196            E::Constraint(msg) => ErrorKind::Constraint(classify_constraint(msg)),
197            E::Misuse(_) => ErrorKind::Misuse,
198            E::Error(msg) if is_conflict(msg) => ErrorKind::Busy,
199            E::Http(_) | E::NotAdb(_) | E::Corrupt(_) => ErrorKind::Connection,
200            _ => ErrorKind::Other,
201        };
202        Error::Remote { kind, source }
203    }
204}
205
206/// Whether a generic engine error reports an MVCC conflict, which a retry
207/// of the whole transaction can resolve.
208///
209/// The engine words them `Write-write conflict`, `Conflict: …` and
210/// `Database schema conflict`. Matching those phrases rather than the bare
211/// word keeps deterministic errors that merely mention a conflict, such as
212/// a parse error about `ON CONFLICT` clauses, out of the retryable kind.
213fn is_conflict(msg: &str) -> bool {
214    let lower = msg.to_ascii_lowercase();
215    lower.contains("write-write conflict")
216        || lower.starts_with("conflict:")
217        || lower.contains("schema conflict")
218}
219
220/// Classifies a constraint violation from the engine's message, which is
221/// the only place the constraint kind is reported.
222fn classify_constraint(msg: &str) -> ConstraintKind {
223    let lower = msg.to_ascii_lowercase();
224    if lower.contains("unique") || lower.contains("primary key") {
225        ConstraintKind::Unique
226    } else if lower.contains("foreign key") {
227        ConstraintKind::ForeignKey
228    } else if lower.contains("not null") {
229        ConstraintKind::NotNull
230    } else if lower.contains("check") {
231        ConstraintKind::Check
232    } else {
233        ConstraintKind::Other
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    /// Engine errors are classified by variant, and MVCC conflicts hidden
242    /// in a generic error message are recognised as busy.
243    #[test]
244    fn classifies() {
245        let e: Error = turso::Error::Constraint("UNIQUE constraint failed: t.a".into()).into();
246        assert_eq!(e.constraint(), Some(ConstraintKind::Unique));
247        let e: Error = turso::Error::Busy("database is locked".into()).into();
248        assert!(e.is_busy());
249        let e: Error = turso::Error::Error("write-write conflict".into()).into();
250        assert!(e.is_busy());
251        let e: Error = turso::Error::Error("syntax error".into()).into();
252        assert_eq!(e.kind(), ErrorKind::Other);
253    }
254
255    /// Each conflict message of the engine is busy, while a parse error that
256    /// mentions `ON CONFLICT` is not.
257    #[test]
258    fn classifies_conflicts_by_phrase() {
259        for msg in [
260            "Write-write conflict",
261            "Conflict: row 3 was modified",
262            "Database schema conflict",
263        ] {
264            let e: Error = turso::Error::Error(msg.into()).into();
265            assert!(e.is_busy(), "{msg}");
266        }
267        let e: Error =
268            turso::Error::Error("Parse error: conflicting ON CONFLICT clauses specified".into())
269                .into();
270        assert_eq!(e.kind(), ErrorKind::Other);
271    }
272}