Skip to main content

ff_backend_sqlite/
errors.rs

1//! SQLite error classification — paralleling
2//! `ff-backend-postgres::is_retryable_serialization`.
3//!
4//! RFC-023 §4.3: `SQLITE_BUSY` / `SQLITE_BUSY_TIMEOUT` / `SQLITE_LOCKED`
5//! map to retry. Non-retryable kinds (`SQLITE_CORRUPT`, `SQLITE_FULL`,
6//! etc.) surface as hard errors. Phase 1a landed the skeleton;
7//! Phase 2a.1 wires it into [`crate::retry::retry_serializable`]
8//! and re-exports [`MAX_ATTEMPTS`] alongside the classifier so
9//! Wave-9 op modules can pull both symbols from a single path.
10
11#[cfg(test)]
12use sqlx::error::Error as SqlxError;
13
14use ff_core::engine_error::EngineError;
15
16/// Re-export of the retry budget so callers have one import path for
17/// classifier + budget + helper.
18pub use crate::retry::MAX_ATTEMPTS;
19
20/// Translate a `sqlx::Error` surfaced during a SQLite op into the
21/// canonical [`EngineError`] shape. Mirrors
22/// `ff-backend-postgres::error::map_sqlx_error`: transient transport
23/// faults box through [`EngineError::Transport`] with
24/// `backend = "sqlite"`; retry-classification happens at the retry
25/// helper layer via [`IsRetryableBusy`] on the translated error.
26pub(crate) fn map_sqlx_error(err: sqlx::Error) -> EngineError {
27    EngineError::Transport {
28        backend: "sqlite",
29        source: Box::new(err),
30    }
31}
32
33/// Let the Wave-9 retry helper classify [`EngineError`] values without
34/// unwrapping — the closure passed to
35/// [`crate::retry::retry_serializable`] returns `Result<_, EngineError>`
36/// and we inspect the boxed transport source to decide whether to loop.
37impl crate::retry::IsRetryableBusy for EngineError {
38    fn is_retryable_busy(&self) -> bool {
39        if let EngineError::Transport { backend, source } = self
40            && *backend == "sqlite"
41            && let Some(sqlx_err) = source.downcast_ref::<sqlx::Error>()
42        {
43            return is_retryable_sqlite_busy(sqlx_err);
44        }
45        false
46    }
47}
48
49/// Return `true` when the sqlx error is a transient busy-contention
50/// fault that is safe to retry. Mirrors the shape of PG's
51/// `is_retryable_serialization` classifier.
52///
53/// Wave-9 SERIALIZABLE ops wrap the classifier via
54/// [`crate::retry::retry_serializable`].
55pub fn is_retryable_sqlite_busy(err: &sqlx::Error) -> bool {
56    if let sqlx::Error::Database(db_err) = err {
57        // sqlx's SQLite driver surfaces the *extended* result code via
58        // `DatabaseError::code()` (see `sqlx_sqlite::SqliteError` —
59        // uses `sqlite3_extended_errcode`). We must match the primary
60        // codes (5, 6) AND every extended code whose low 8 bits are
61        // 5 or 6 so BUSY_RECOVERY / BUSY_SNAPSHOT / BUSY_TIMEOUT /
62        // LOCKED_SHAREDCACHE / LOCKED_VTAB all classify as retryable.
63        if let Some(code) = db_err.code()
64            && let Ok(n) = code.parse::<i32>()
65        {
66            let primary = n & 0xff;
67            return primary == 5 || primary == 6;
68        }
69    }
70    false
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn non_db_error_is_not_retryable() {
79        let err = SqlxError::RowNotFound;
80        assert!(!is_retryable_sqlite_busy(&err));
81    }
82}