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}