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}