Skip to main content

hyperdb_api_core/client/
error.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Error types for the Hyper client.
5
6use std::error::Error as StdError;
7use std::fmt;
8use std::io;
9
10/// The error type for Hyper client operations.
11#[derive(Debug)]
12pub struct Error {
13    kind: ErrorKind,
14    message: String,
15    cause: Option<Box<dyn StdError + Send + Sync>>,
16    /// SQLSTATE error code (for query errors)
17    sqlstate_code: Option<String>,
18    /// Additional detail about the error
19    detail: Option<String>,
20    /// Hint for resolving the error
21    hint: Option<String>,
22}
23
24/// The kind of error that occurred.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ErrorKind {
27    /// Connection failed.
28    Connection,
29    /// Authentication failed.
30    Authentication,
31    /// Query execution failed.
32    Query,
33    /// Invalid response from server.
34    Protocol,
35    /// I/O error.
36    Io,
37    /// Configuration error.
38    Config,
39    /// Operation timed out.
40    Timeout,
41    /// Operation was cancelled.
42    Cancelled,
43    /// The connection was closed.
44    Closed,
45    /// Type conversion error.
46    Conversion,
47    /// Feature not supported by this connection type.
48    FeatureNotSupported,
49    /// Other error.
50    Other,
51}
52
53impl Error {
54    /// Creates a new error with the given kind and message.
55    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
56        Error {
57            kind,
58            message: message.into(),
59            cause: None,
60            sqlstate_code: None,
61            detail: None,
62            hint: None,
63        }
64    }
65
66    /// Creates a new error with a cause.
67    pub fn with_cause<E>(kind: ErrorKind, message: impl Into<String>, cause: E) -> Self
68    where
69        E: Into<Box<dyn StdError + Send + Sync>>,
70    {
71        Error {
72            kind,
73            message: message.into(),
74            cause: Some(cause.into()),
75            sqlstate_code: None,
76            detail: None,
77            hint: None,
78        }
79    }
80
81    /// Creates a new error with additional details (SQLSTATE, detail, hint).
82    ///
83    /// This is primarily used for gRPC errors that carry structured error information.
84    pub fn new_with_details(
85        kind: ErrorKind,
86        message: impl Into<String>,
87        detail: Option<String>,
88        hint: Option<String>,
89        sqlstate: Option<String>,
90    ) -> Self {
91        Error {
92            kind,
93            message: message.into(),
94            cause: None,
95            sqlstate_code: sqlstate,
96            detail,
97            hint,
98        }
99    }
100
101    /// Returns the error kind.
102    #[must_use]
103    pub fn kind(&self) -> ErrorKind {
104        self.kind
105    }
106
107    /// Returns the error message.
108    #[must_use]
109    pub fn message(&self) -> &str {
110        &self.message
111    }
112
113    /// Returns the error detail, if available.
114    #[must_use]
115    pub fn detail(&self) -> Option<&str> {
116        self.detail.as_deref()
117    }
118
119    /// Returns the error hint, if available.
120    #[must_use]
121    pub fn hint(&self) -> Option<&str> {
122        self.hint.as_deref()
123    }
124
125    // Convenience constructors
126
127    /// Creates a connection error.
128    pub fn connection(message: impl Into<String>) -> Self {
129        Self::new(ErrorKind::Connection, message)
130    }
131
132    /// Creates an authentication error.
133    pub fn authentication(message: impl Into<String>) -> Self {
134        Self::new(ErrorKind::Authentication, message)
135    }
136
137    /// Creates a query error.
138    pub fn query(message: impl Into<String>) -> Self {
139        Self::new(ErrorKind::Query, message)
140    }
141
142    /// Creates a protocol error.
143    pub fn protocol(message: impl Into<String>) -> Self {
144        Self::new(ErrorKind::Protocol, message)
145    }
146
147    /// Creates a closed connection error.
148    #[must_use]
149    pub fn closed() -> Self {
150        Self::new(ErrorKind::Closed, "connection closed")
151    }
152
153    /// Creates a timeout error.
154    #[must_use]
155    pub fn timeout() -> Self {
156        Self::new(ErrorKind::Timeout, "operation timed out")
157    }
158
159    /// Creates an error from an I/O error.
160    #[must_use]
161    pub fn io(err: io::Error) -> Self {
162        Self::with_cause(ErrorKind::Io, err.to_string(), err)
163    }
164
165    /// Creates an error from a database error response.
166    #[must_use]
167    pub fn db(severity: &str, code: &str, message: &str) -> Self {
168        Error {
169            kind: ErrorKind::Query,
170            message: format!("{severity}: {message} ({code})"),
171            cause: None,
172            sqlstate_code: Some(code.to_string()),
173            detail: None,
174            hint: None,
175        }
176    }
177
178    /// Creates a "feature not supported" error.
179    ///
180    /// Used when an operation is not available on a particular connection type
181    /// (e.g., write operations on gRPC connections).
182    pub fn feature_not_supported(message: impl Into<String>) -> Self {
183        Self::new(ErrorKind::FeatureNotSupported, message)
184    }
185
186    /// Creates a generic "other" error.
187    pub fn other(message: impl Into<String>) -> Self {
188        Self::new(ErrorKind::Other, message)
189    }
190
191    /// Extracts the `PostgreSQL` SQLSTATE code from the error, if present.
192    ///
193    /// SQLSTATE codes are 5-character codes that identify error conditions.
194    /// See: <https://www.postgresql.org/docs/current/errcodes-appendix.html>
195    ///
196    /// # Example
197    ///
198    /// ```
199    /// use hyperdb_api_core::client::{Error, ErrorKind};
200    ///
201    /// let err = Error::db("ERROR", "42P04", "database already exists");
202    /// assert_eq!(err.sqlstate(), Some("42P04"));
203    /// ```
204    #[must_use]
205    pub fn sqlstate(&self) -> Option<&str> {
206        // First check if we have a stored SQLSTATE code
207        if let Some(ref code) = self.sqlstate_code {
208            return Some(code);
209        }
210        // Fall back to extracting from message for backwards compatibility
211        if self.kind == ErrorKind::Query {
212            extract_sqlstate(&self.message)
213        } else {
214            None
215        }
216    }
217}
218
219/// Extracts the SQLSTATE code from a Hyper error message.
220///
221/// Hyper error messages have the format: "SEVERITY: message (CODE)"
222/// where CODE is the 5-character SQLSTATE code.
223fn extract_sqlstate(message: &str) -> Option<&str> {
224    // Find the last occurrence of '(' which should contain the SQLSTATE code
225    let start = message.rfind('(')?;
226    let end = message[start..].find(')')?;
227
228    let code = message[start + 1..start + end].trim();
229
230    // Validate that it looks like a SQLSTATE code (5 alphanumeric characters)
231    if code.len() == 5 && code.chars().all(|c| c.is_ascii_alphanumeric()) {
232        Some(code)
233    } else {
234        None
235    }
236}
237
238impl fmt::Display for Error {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        write!(f, "{}", self.message)?;
241        if let Some(ref detail) = self.detail {
242            if !self.message.contains(detail) {
243                write!(f, ": {detail}")?;
244            }
245        }
246        if let Some(ref cause) = self.cause {
247            write!(f, ": {cause}")?;
248        }
249        Ok(())
250    }
251}
252
253impl StdError for Error {
254    fn source(&self) -> Option<&(dyn StdError + 'static)> {
255        self.cause.as_ref().map(|e| &**e as &dyn std::error::Error)
256    }
257}
258
259impl From<io::Error> for Error {
260    fn from(err: io::Error) -> Self {
261        Error::io(err)
262    }
263}
264
265/// Result type for Hyper client operations.
266pub type Result<T> = std::result::Result<T, Error>;
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_sqlstate_extraction() {
274        // Standard format: "SEVERITY: message (CODE)"
275        let err = Error::db("ERROR", "42P04", "database \"test\" already exists");
276        assert_eq!(err.sqlstate(), Some("42P04"));
277
278        // Duplicate object
279        let err = Error::db("ERROR", "42710", "duplicate object");
280        assert_eq!(err.sqlstate(), Some("42710"));
281
282        // Duplicate schema
283        let err = Error::db("ERROR", "42P06", "schema \"public\" already exists");
284        assert_eq!(err.sqlstate(), Some("42P06"));
285
286        // Duplicate table
287        let err = Error::db("ERROR", "42P07", "table \"users\" already exists");
288        assert_eq!(err.sqlstate(), Some("42P07"));
289    }
290
291    #[test]
292    fn test_sqlstate_non_query_error() {
293        // Non-query errors should not have SQLSTATE
294        let err = Error::connection("connection failed");
295        assert_eq!(err.sqlstate(), None);
296
297        let err = Error::timeout();
298        assert_eq!(err.sqlstate(), None);
299    }
300
301    #[test]
302    fn test_extract_sqlstate_edge_cases() {
303        // Valid SQLSTATE
304        assert_eq!(extract_sqlstate("ERROR: message (42P04)"), Some("42P04"));
305
306        // With spaces
307        assert_eq!(extract_sqlstate("ERROR: message ( 42P04 )"), Some("42P04"));
308
309        // Multiple parentheses (should extract last one)
310        assert_eq!(
311            extract_sqlstate("ERROR: (extra info) message (42P04)"),
312            Some("42P04")
313        );
314
315        // Invalid: too short
316        assert_eq!(extract_sqlstate("ERROR: message (42P)"), None);
317
318        // Invalid: too long
319        assert_eq!(extract_sqlstate("ERROR: message (42P044)"), None);
320
321        // Invalid: non-alphanumeric
322        assert_eq!(extract_sqlstate("ERROR: message (42-04)"), None);
323
324        // No parentheses
325        assert_eq!(extract_sqlstate("ERROR: message"), None);
326
327        // Empty parentheses
328        assert_eq!(extract_sqlstate("ERROR: message ()"), None);
329    }
330}