Skip to main content

cratestack_sqlx/
error.rs

1//! Typed conversion from `sqlx::Error` to `CoolError`.
2//!
3//! The central entry point is [`cool_error_from_sqlx`], which should be used
4//! instead of `CoolError::Database(error.to_string())` at every sqlx call
5//! site.  When the underlying error is `sqlx::Error::Database`, the
6//! structured fields (`code`, `constraint`) are captured in a
7//! [`DbErrorInfo`][cratestack_core::DbErrorInfo] and stored in the
8//! [`CoolError::DatabaseTyped`] variant so consumers can call
9//! [`CoolError::db_sqlstate`] / [`CoolError::db_constraint`] instead of
10//! substring-matching the stringified detail.
11//!
12//! `sqlx::Error::RowNotFound` is mapped to `CoolError::NotFound` so a missing
13//! row surfaces as a 404 rather than a 500. Callers that want a custom
14//! not-found message should construct it themselves before calling this
15//! helper.
16
17use cratestack_core::{CoolError, DbErrorInfo};
18
19use crate::sqlx;
20
21/// Convert a `sqlx::Error` to `CoolError`, preserving structured database
22/// error information when available.
23///
24/// # When a typed variant is produced
25///
26/// If `error` is `sqlx::Error::Database(db_err)`, this function produces
27/// `CoolError::DatabaseTyped` with the SQLSTATE code and constraint name
28/// extracted from the driver error.  All other `sqlx::Error` kinds (pool
29/// timeouts, decode errors, etc.) fall back to `CoolError::Database` with the
30/// stringified message, identical to the legacy `error.to_string()` path.
31///
32/// # Usage
33///
34/// ```rust,ignore
35/// use cratestack_sqlx::cool_error_from_sqlx;
36///
37/// sqlx::query("INSERT …")
38///     .execute(&pool)
39///     .await
40///     .map_err(cool_error_from_sqlx)?;
41/// ```
42///
43/// Consumers can then inspect the error:
44///
45/// ```rust,ignore
46/// if err.db_sqlstate() == Some("23505") {
47///     let constraint = err.db_constraint(); // e.g. "accounts_email_key"
48/// }
49/// ```
50pub fn cool_error_from_sqlx(error: sqlx::Error) -> CoolError {
51    match error {
52        sqlx::Error::Database(db_err) => {
53            let detail = db_err.to_string();
54            let sqlstate = db_err.code().map(|c| c.into_owned());
55            let constraint = db_err.constraint().map(ToOwned::to_owned);
56            CoolError::DatabaseTyped(DbErrorInfo {
57                detail,
58                sqlstate,
59                constraint,
60            })
61        }
62        sqlx::Error::RowNotFound => CoolError::NotFound("not found".to_owned()),
63        other => CoolError::Database(other.to_string()),
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    /// `RowNotFound` is a missing-row signal and must map to `NotFound`
72    /// so callers see a 404 instead of a 500.
73    #[test]
74    fn row_not_found_maps_to_not_found() {
75        let err = cool_error_from_sqlx(sqlx::Error::RowNotFound);
76        assert!(
77            matches!(err, CoolError::NotFound(_)),
78            "RowNotFound should map to CoolError::NotFound",
79        );
80        assert_eq!(err.status_code().as_u16(), 404);
81        // Typed accessors are not applicable to NotFound.
82        assert_eq!(err.db_sqlstate(), None);
83        assert_eq!(err.db_constraint(), None);
84    }
85
86    /// Round-trip: a non-database, non-RowNotFound sqlx error (e.g. a
87    /// configuration / protocol error) must produce the legacy
88    /// `Database(String)` variant so existing `detail()` callers keep
89    /// working.
90    #[test]
91    fn non_database_sqlx_error_produces_legacy_variant() {
92        let err = cool_error_from_sqlx(sqlx::Error::Protocol(
93            "unexpected EOF from server".to_owned(),
94        ));
95        assert!(
96            matches!(err, CoolError::Database(_)),
97            "Protocol error should fall back to CoolError::Database",
98        );
99        assert!(
100            err.detail().is_some(),
101            "detail() must not be empty for non-database errors",
102        );
103        // Typed accessors return None for the legacy variant.
104        assert_eq!(err.db_sqlstate(), None);
105        assert_eq!(err.db_constraint(), None);
106    }
107
108    /// The `DatabaseTyped` variant exposes sqlstate and constraint through the
109    /// typed accessors, and its `detail()` still returns the full operator
110    /// string (same as the old `error.to_string()` value).
111    ///
112    /// We can't easily construct a real `PgDatabaseError` in a unit test
113    /// (it's opaque), so we verify the round-trip contract using
114    /// `DbErrorInfo` directly and confirm `cool_error_from_sqlx` maps
115    /// the correct variant for the Database arm.
116    #[test]
117    fn database_typed_accessors() {
118        let info = DbErrorInfo {
119            detail: "ERROR: duplicate key value violates unique constraint \"accounts_email_key\""
120                .to_owned(),
121            sqlstate: Some("23505".to_owned()),
122            constraint: Some("accounts_email_key".to_owned()),
123        };
124        let err = CoolError::DatabaseTyped(info);
125
126        assert_eq!(err.db_sqlstate(), Some("23505"));
127        assert_eq!(err.db_constraint(), Some("accounts_email_key"));
128        assert_eq!(err.code(), "DATABASE_ERROR");
129        // 5xx — must map to 500.
130        let status = err.status_code();
131        assert_eq!(status.as_u16(), 500);
132        assert_eq!(err.public_message(), "internal error");
133        assert!(err.detail().unwrap().contains("duplicate key"));
134    }
135
136    /// `is_retriable` in `isolation.rs` matches on `detail()`.
137    /// Both `Database(String)` and `DatabaseTyped` must surface their detail
138    /// so the retry logic continues to work for serialization failures.
139    #[test]
140    fn database_typed_detail_preserved_for_retry_logic() {
141        let info = DbErrorInfo {
142            detail: "Database(PgDatabaseError { code: \"40001\", message: \"could not serialize access\" })"
143                .to_owned(),
144            sqlstate: Some("40001".to_owned()),
145            constraint: None,
146        };
147        let err = CoolError::DatabaseTyped(info);
148        let detail = err.detail().unwrap_or_default();
149        assert!(
150            detail.contains("40001") || detail.contains("serialize"),
151            "detail must still surface retriable substrings: {detail}",
152        );
153    }
154
155    /// `DatabaseTyped` with an empty detail must return `None` from `detail()`,
156    /// consistent with the `Database(String)` behaviour.
157    #[test]
158    fn database_typed_empty_detail_returns_none() {
159        let err = CoolError::DatabaseTyped(DbErrorInfo::default());
160        assert_eq!(err.detail(), None);
161    }
162
163    /// `into_response` must never leak the operator detail for DatabaseTyped.
164    #[test]
165    fn database_typed_into_response_does_not_leak_detail() {
166        let info = DbErrorInfo {
167            detail: "SELECT * FROM secrets".to_owned(),
168            sqlstate: Some("23505".to_owned()),
169            constraint: None,
170        };
171        let response = CoolError::DatabaseTyped(info).into_response();
172        assert_eq!(response.code, "DATABASE_ERROR");
173        assert_eq!(response.message, "internal error");
174        assert!(!response.message.contains("secrets"));
175        assert!(response.details.is_none());
176    }
177}