Skip to main content

prax_postgres/
error.rs

1//! Error types for PostgreSQL operations.
2
3use prax_query::QueryError;
4use thiserror::Error;
5
6/// Result type for PostgreSQL operations.
7pub type PgResult<T> = Result<T, PgError>;
8
9/// Errors that can occur during PostgreSQL operations.
10#[derive(Error, Debug)]
11pub enum PgError {
12    /// Connection pool error.
13    #[error("pool error: {0}")]
14    Pool(#[from] deadpool_postgres::PoolError),
15
16    /// PostgreSQL error.
17    #[error("postgres error: {0}")]
18    Postgres(#[from] tokio_postgres::Error),
19
20    /// Configuration error.
21    #[error("configuration error: {0}")]
22    Config(String),
23
24    /// Connection error.
25    #[error("connection error: {0}")]
26    Connection(String),
27
28    /// Query execution error.
29    #[error("query error: {0}")]
30    Query(String),
31
32    /// Row deserialization error.
33    #[error("deserialization error: {0}")]
34    Deserialization(String),
35
36    /// Type conversion error.
37    #[error("type conversion error: {0}")]
38    TypeConversion(String),
39
40    /// Timeout error.
41    #[error("operation timed out after {0}ms")]
42    Timeout(u64),
43
44    /// Internal error.
45    #[error("internal error: {0}")]
46    Internal(String),
47}
48
49impl PgError {
50    /// Create a configuration error.
51    pub fn config(message: impl Into<String>) -> Self {
52        Self::Config(message.into())
53    }
54
55    /// Create a connection error.
56    pub fn connection(message: impl Into<String>) -> Self {
57        Self::Connection(message.into())
58    }
59
60    /// Create a query error.
61    pub fn query(message: impl Into<String>) -> Self {
62        Self::Query(message.into())
63    }
64
65    /// Create a deserialization error.
66    pub fn deserialization(message: impl Into<String>) -> Self {
67        Self::Deserialization(message.into())
68    }
69
70    /// Create a type conversion error.
71    pub fn type_conversion(message: impl Into<String>) -> Self {
72        Self::TypeConversion(message.into())
73    }
74
75    /// Check if this is a connection error.
76    pub fn is_connection_error(&self) -> bool {
77        matches!(self, Self::Pool(_) | Self::Connection(_))
78    }
79
80    /// Check if this is a timeout error.
81    pub fn is_timeout(&self) -> bool {
82        matches!(self, Self::Timeout(_))
83    }
84}
85
86/// Map a PostgreSQL SQLSTATE code (and message) to a [`QueryError`].
87///
88/// Split out from the `From<PgError>` impl so the classification can be
89/// exercised directly in tests — a `tokio_postgres::Error` carrying a chosen
90/// SQLSTATE cannot be constructed by hand.
91///
92/// Recognized classes:
93///   * `23505` unique, `23503` foreign key, `23514` check → constraint
94///     violations.
95///   * `23502` not-null → invalid input.
96///   * `0A000` "cached plan must not change result type" → a *retryable*
97///     stale-plan error, so a pooled prepared statement invalidated by DDL is
98///     classified retryable (see [`QueryError::stale_plan`]) instead of an
99///     opaque generic database error. `0A000` is the shared
100///     `FEATURE_NOT_SUPPORTED` class, so this is gated on the server's message
101///     — other `0A000` conditions (genuinely unsupported features) are
102///     terminal and stay generic.
103///   * anything else → a generic database error.
104///
105/// `display` is the driver error's `Display` text (used as the error message);
106/// `detail` is the server's message string (`DbError::message`), used only for
107/// the `0A000` gate — a `tokio_postgres::Error` renders a DB error as just
108/// "db error" via `Display`, so the cached-plan text is only visible in the
109/// DbError. When `detail` is `None` (no DbError, e.g. a synthesized error) the
110/// gate falls back to `display`.
111pub(crate) fn classify_sqlstate(
112    code: Option<&str>,
113    display: &str,
114    detail: Option<&str>,
115) -> QueryError {
116    let gate_text = detail.unwrap_or(display);
117    match code {
118        // Unique / foreign key / check violations.
119        Some("23505") | Some("23503") | Some("23514") => {
120            QueryError::constraint_violation("", display)
121        }
122        // Not null violation.
123        Some("23502") => QueryError::invalid_input("", display),
124        // Stale server-side prepared plan after DDL — transient, retry. The
125        // 0A000 class also covers real "feature not supported" errors, which
126        // are terminal, so match the specific cached-plan message.
127        Some("0A000") if gate_text.contains("cached plan must not change result type") => {
128            QueryError::stale_plan(display)
129        }
130        _ => QueryError::database(display),
131    }
132}
133
134impl From<PgError> for QueryError {
135    fn from(err: PgError) -> Self {
136        match err {
137            PgError::Pool(e) => QueryError::connection(e.to_string()),
138            PgError::Postgres(e) => {
139                // Categorize by SQLSTATE while preserving the driver error
140                // as the source. The cached-plan gate needs the server's
141                // message, which lives in the DbError — `Display` renders a DB
142                // error as just "db error".
143                let code_str = e.code().map(|c| c.code().to_owned());
144                let display = e.to_string();
145                let detail = e.as_db_error().map(|db| db.message().to_owned());
146                let mapped = classify_sqlstate(code_str.as_deref(), &display, detail.as_deref());
147                mapped.with_source(e)
148            }
149            PgError::Config(msg) => QueryError::connection(msg),
150            PgError::Connection(msg) => QueryError::connection(msg),
151            PgError::Query(msg) => QueryError::database(msg),
152            PgError::Deserialization(msg) => QueryError::serialization(msg),
153            PgError::TypeConversion(msg) => QueryError::serialization(msg),
154            PgError::Timeout(ms) => QueryError::timeout(ms),
155            PgError::Internal(msg) => QueryError::internal(msg),
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_error_creation() {
166        let err = PgError::config("invalid URL");
167        assert!(matches!(err, PgError::Config(_)));
168
169        let err = PgError::connection("connection refused");
170        assert!(err.is_connection_error());
171
172        let err = PgError::Timeout(5000);
173        assert!(err.is_timeout());
174    }
175
176    #[test]
177    fn test_into_query_error() {
178        let pg_err = PgError::Timeout(1000);
179        let query_err: QueryError = pg_err.into();
180        assert!(query_err.is_timeout());
181    }
182
183    #[test]
184    fn test_classify_constraint_sqlstates() {
185        use prax_query::ErrorCode;
186        // Unique, foreign key, and check all classify as constraint violations.
187        for code in ["23505", "23503", "23514"] {
188            let e = classify_sqlstate(Some(code), "boom", None);
189            assert_eq!(e.code, ErrorCode::UniqueConstraint, "code {code}");
190            assert!(e.is_constraint_violation(), "code {code}");
191        }
192        // Not-null maps to invalid input.
193        assert_eq!(
194            classify_sqlstate(Some("23502"), "boom", None).code,
195            ErrorCode::InvalidParameter
196        );
197    }
198
199    #[test]
200    fn test_classify_stale_cached_plan_gates_on_detail() {
201        use prax_query::ErrorCode;
202        // Real Postgres path: Display is just "db error", the cached-plan text
203        // is only in the DbError detail. The gate must read `detail`.
204        let e = classify_sqlstate(
205            Some("0A000"),
206            "db error",
207            Some("cached plan must not change result type"),
208        );
209        assert_eq!(e.code, ErrorCode::SerializationFailure);
210        assert!(e.is_retryable());
211
212        // If the cached-plan text were only in Display (no detail), the
213        // fallback still catches it — defends the synthesized-error path.
214        let e = classify_sqlstate(
215            Some("0A000"),
216            "cached plan must not change result type",
217            None,
218        );
219        assert_eq!(e.code, ErrorCode::SerializationFailure);
220    }
221
222    #[test]
223    fn test_classify_other_0a000_stays_generic() {
224        use prax_query::ErrorCode;
225        // A genuine "feature not supported" 0A000 is terminal, not retryable —
226        // even though Display is "db error", the detail is not the cached-plan
227        // text.
228        let e = classify_sqlstate(Some("0A000"), "db error", Some("cannot insert into a view"));
229        assert_eq!(e.code, ErrorCode::DatabaseError);
230        assert!(!e.is_retryable());
231    }
232
233    #[test]
234    fn test_classify_unknown_sqlstate_is_generic() {
235        use prax_query::ErrorCode;
236        assert_eq!(
237            classify_sqlstate(Some("40P01"), "deadlock-ish", None).code,
238            ErrorCode::DatabaseError
239        );
240        assert_eq!(
241            classify_sqlstate(None, "no code", None).code,
242            ErrorCode::DatabaseError
243        );
244    }
245}