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//!
6//! [`Error`] is a flat enum: one variant per failure mode, matched
7//! directly, with no `kind()` discriminator and no `Box<dyn StdError>`
8//! cause channel. That is the shape the [Microsoft Pragmatic Rust
9//! Guidelines][msrg] call for (M-ERRORS-CANONICAL-STRUCTS,
10//! M-ERRORS-AVOID-WRAPPING-AND-AS-DYN), and it mirrors the public
11//! `hyperdb_api::Error` this type feeds.
12//!
13//! This type is **internal**. It is not re-exported from `hyperdb-api`;
14//! callers of the public API match on `hyperdb_api::Error` instead, which
15//! `From<client::Error>` produces.
16//!
17//! [msrg]: https://microsoft.github.io/rust-guidelines/
18
19use std::io;
20
21use thiserror::Error as ThisError;
22
23/// The error type for Hyper client operations.
24///
25/// Variants that can carry a server-supplied SQLSTATE expose it as a
26/// field, so callers match on it structurally rather than scraping the
27/// message. The remaining variants are single-string: whatever context
28/// exists is already rendered into that string by the constructor.
29///
30/// Deliberately **not** `#[non_exhaustive]`. That attribute buys
31/// forward-compatibility for downstream matches, which this type has no
32/// use for: it is internal, not re-exported, and `hyperdb-api` is its only
33/// consumer and ships from this same workspace in lockstep. What it would
34/// cost is the compile-time exhaustiveness check on
35/// `From<client::Error> for hyperdb_api::Error` — the one place a new
36/// variant must be given a public mapping. Better that adding a variant
37/// breaks that build than silently degrades to `Error::Internal`.
38#[derive(Debug, ThisError)]
39pub enum Error {
40    /// Connection failed.
41    #[error("{message}")]
42    Connection {
43        /// Human-readable description of the failure.
44        message: String,
45        /// SQLSTATE, when the failure arrived from the server (gRPC
46        /// reports connection-class SQLSTATEs in the `08xxx` family).
47        sqlstate: Option<String>,
48    },
49
50    /// Authentication failed.
51    #[error("{0}")]
52    Authentication(String),
53
54    /// Query execution failed.
55    ///
56    /// The one variant that carries the server's full diagnostic
57    /// payload — `DETAIL` and `HINT` are surfaced verbatim by the public
58    /// `hyperdb_api::Error::Server` variant this maps to.
59    #[error("{message}{}", render_detail(message, detail.as_deref()))]
60    Query {
61        /// The primary error message.
62        message: String,
63        /// SQLSTATE code, when the server supplied one.
64        sqlstate: Option<String>,
65        /// The server's `DETAIL` field.
66        detail: Option<String>,
67        /// The server's `HINT` field.
68        hint: Option<String>,
69    },
70
71    /// Invalid response from server.
72    #[error("{0}")]
73    Protocol(String),
74
75    /// I/O error.
76    #[error("{0}")]
77    Io(String),
78
79    /// Configuration error.
80    #[error("{0}")]
81    Config(String),
82
83    /// Operation timed out.
84    #[error("{0}")]
85    Timeout(String),
86
87    /// Operation was cancelled.
88    #[error("{message}")]
89    Cancelled {
90        /// Human-readable description of the cancellation.
91        message: String,
92        /// SQLSTATE, typically `57014` (`query_canceled`).
93        sqlstate: Option<String>,
94    },
95
96    /// The connection was closed.
97    #[error("{message}")]
98    Closed {
99        /// Human-readable description.
100        message: String,
101        /// SQLSTATE, when the server supplied one.
102        sqlstate: Option<String>,
103    },
104
105    /// Type conversion error.
106    #[error("{0}")]
107    Conversion(String),
108
109    /// Feature not supported by this connection type.
110    #[error("{0}")]
111    FeatureNotSupported(String),
112
113    /// Other error.
114    #[error("{0}")]
115    Other(String),
116}
117
118/// Renders the `": {detail}"` suffix for [`Error::Query`]'s `Display`,
119/// suppressing it when `message` already contains the detail text.
120///
121/// gRPC's `decode_error_info` builds `"{primary}: {customer_detail}"` as
122/// the message and *also* reports `customer_detail` separately, so without
123/// this guard the detail would print twice.
124fn render_detail(message: &str, detail: Option<&str>) -> String {
125    match detail {
126        Some(detail) if !message.contains(detail) => format!(": {detail}"),
127        _ => String::new(),
128    }
129}
130
131impl Error {
132    // Constructors. Every variant has one taking `impl Into<String>`;
133    // the variants with a SQLSTATE field default it to `None` here and
134    // are built with struct literals where a code is available.
135
136    /// Creates a connection error with no SQLSTATE.
137    pub fn connection(message: impl Into<String>) -> Self {
138        Error::Connection {
139            message: message.into(),
140            sqlstate: None,
141        }
142    }
143
144    /// Creates an authentication error.
145    pub fn authentication(message: impl Into<String>) -> Self {
146        Error::Authentication(message.into())
147    }
148
149    /// Creates a query error with no SQLSTATE, detail, or hint.
150    pub fn query(message: impl Into<String>) -> Self {
151        Error::Query {
152            message: message.into(),
153            sqlstate: None,
154            detail: None,
155            hint: None,
156        }
157    }
158
159    /// Creates a protocol error.
160    pub fn protocol(message: impl Into<String>) -> Self {
161        Error::Protocol(message.into())
162    }
163
164    /// Creates an I/O error from a message.
165    ///
166    /// Prefer [`Error::from_io`] when an [`io::Error`] is in hand.
167    pub fn io(message: impl Into<String>) -> Self {
168        Error::Io(message.into())
169    }
170
171    /// Creates a configuration error.
172    pub fn config(message: impl Into<String>) -> Self {
173        Error::Config(message.into())
174    }
175
176    /// Creates a timeout error.
177    pub fn timeout(message: impl Into<String>) -> Self {
178        Error::Timeout(message.into())
179    }
180
181    /// Creates a cancellation error with no SQLSTATE.
182    pub fn cancelled(message: impl Into<String>) -> Self {
183        Error::Cancelled {
184            message: message.into(),
185            sqlstate: None,
186        }
187    }
188
189    /// Creates a closed-connection error with no SQLSTATE.
190    pub fn closed(message: impl Into<String>) -> Self {
191        Error::Closed {
192            message: message.into(),
193            sqlstate: None,
194        }
195    }
196
197    /// Creates a type-conversion error.
198    pub fn conversion(message: impl Into<String>) -> Self {
199        Error::Conversion(message.into())
200    }
201
202    /// Creates a "feature not supported" error.
203    ///
204    /// Used when an operation is not available on a particular connection
205    /// type (e.g. write operations on gRPC connections).
206    pub fn feature_not_supported(message: impl Into<String>) -> Self {
207        Error::FeatureNotSupported(message.into())
208    }
209
210    /// Creates a generic "other" error.
211    pub fn other(message: impl Into<String>) -> Self {
212        Error::Other(message.into())
213    }
214
215    // Convenience constructors for the common shapes.
216
217    /// Creates an I/O error from an [`io::Error`].
218    ///
219    /// Takes the error by value so it can be used point-free as
220    /// `.map_err(Error::from_io)`.
221    #[expect(
222        clippy::needless_pass_by_value,
223        reason = "call-site ergonomics: consumed as a `map_err` function reference"
224    )]
225    #[must_use]
226    pub fn from_io(err: io::Error) -> Self {
227        Error::Io(err.to_string())
228    }
229
230    /// Creates an error from a database error response.
231    #[must_use]
232    pub fn db(severity: &str, code: &str, message: &str) -> Self {
233        Error::Query {
234            message: format!("{severity}: {message} ({code})"),
235            sqlstate: Some(code.to_string()),
236            detail: None,
237            hint: None,
238        }
239    }
240
241    /// Returns the error message, without any `DETAIL` suffix that
242    /// `Display` would append.
243    #[must_use]
244    pub fn message(&self) -> &str {
245        match self {
246            Error::Connection { message, .. }
247            | Error::Query { message, .. }
248            | Error::Cancelled { message, .. }
249            | Error::Closed { message, .. } => message,
250            Error::Authentication(message)
251            | Error::Protocol(message)
252            | Error::Io(message)
253            | Error::Config(message)
254            | Error::Timeout(message)
255            | Error::Conversion(message)
256            | Error::FeatureNotSupported(message)
257            | Error::Other(message) => message,
258        }
259    }
260
261    /// Returns the error detail, if available.
262    #[must_use]
263    pub fn detail(&self) -> Option<&str> {
264        match self {
265            Error::Query { detail, .. } => detail.as_deref(),
266            _ => None,
267        }
268    }
269
270    /// Returns the error hint, if available.
271    #[must_use]
272    pub fn hint(&self) -> Option<&str> {
273        match self {
274            Error::Query { hint, .. } => hint.as_deref(),
275            _ => None,
276        }
277    }
278
279    /// Extracts the `PostgreSQL` SQLSTATE code from the error, if present.
280    ///
281    /// SQLSTATE codes are 5-character codes that identify error conditions.
282    /// See: <https://www.postgresql.org/docs/current/errcodes-appendix.html>
283    ///
284    /// For [`Error::Query`] with no stored code, falls back to scraping the
285    /// trailing `(CODE)` that Hyper appends to wire error messages.
286    ///
287    /// # Example
288    ///
289    /// ```
290    /// use hyperdb_api_core::client::Error;
291    ///
292    /// let err = Error::db("ERROR", "42P04", "database already exists");
293    /// assert_eq!(err.sqlstate(), Some("42P04"));
294    /// ```
295    #[must_use]
296    pub fn sqlstate(&self) -> Option<&str> {
297        match self {
298            Error::Connection { sqlstate, .. }
299            | Error::Cancelled { sqlstate, .. }
300            | Error::Closed { sqlstate, .. } => sqlstate.as_deref(),
301            Error::Query {
302                sqlstate, message, ..
303            } => match sqlstate {
304                Some(code) => Some(code),
305                // Backwards compatibility: older paths encode the code in
306                // the message rather than storing it.
307                None => extract_sqlstate(message),
308            },
309            _ => None,
310        }
311    }
312}
313
314/// Extracts the SQLSTATE code from a Hyper error message.
315///
316/// Hyper error messages have the format: "SEVERITY: message (CODE)"
317/// where CODE is the 5-character SQLSTATE code.
318fn extract_sqlstate(message: &str) -> Option<&str> {
319    // Find the last occurrence of '(' which should contain the SQLSTATE code
320    let start = message.rfind('(')?;
321    let end = message[start..].find(')')?;
322
323    let code = message[start + 1..start + end].trim();
324
325    // Validate that it looks like a SQLSTATE code (5 alphanumeric characters)
326    if code.len() == 5 && code.chars().all(|c| c.is_ascii_alphanumeric()) {
327        Some(code)
328    } else {
329        None
330    }
331}
332
333impl From<io::Error> for Error {
334    fn from(err: io::Error) -> Self {
335        Error::from_io(err)
336    }
337}
338
339/// Result type for Hyper client operations.
340pub type Result<T> = std::result::Result<T, Error>;
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_sqlstate_extraction() {
348        // Standard format: "SEVERITY: message (CODE)"
349        let err = Error::db("ERROR", "42P04", "database \"test\" already exists");
350        assert_eq!(err.sqlstate(), Some("42P04"));
351
352        // Duplicate object
353        let err = Error::db("ERROR", "42710", "duplicate object");
354        assert_eq!(err.sqlstate(), Some("42710"));
355
356        // Duplicate schema
357        let err = Error::db("ERROR", "42P06", "schema \"public\" already exists");
358        assert_eq!(err.sqlstate(), Some("42P06"));
359
360        // Duplicate table
361        let err = Error::db("ERROR", "42P07", "table \"users\" already exists");
362        assert_eq!(err.sqlstate(), Some("42P07"));
363    }
364
365    #[test]
366    fn test_sqlstate_non_query_error() {
367        // Non-query errors carry no SQLSTATE unless one was supplied.
368        let err = Error::connection("connection failed");
369        assert_eq!(err.sqlstate(), None);
370
371        let err = Error::timeout("operation timed out");
372        assert_eq!(err.sqlstate(), None);
373    }
374
375    /// The gRPC path builds `Connection` / `Cancelled` / `Closed` with a
376    /// server-supplied SQLSTATE; `sqlstate()` must surface it, because the
377    /// public `hyperdb_api::Error` mapping forwards it to callers.
378    #[test]
379    fn test_sqlstate_on_non_query_variants() {
380        let err = Error::Cancelled {
381            message: "query canceled".to_string(),
382            sqlstate: Some("57014".to_string()),
383        };
384        assert_eq!(err.sqlstate(), Some("57014"));
385
386        let err = Error::Connection {
387            message: "connection failure".to_string(),
388            sqlstate: Some("08006".to_string()),
389        };
390        assert_eq!(err.sqlstate(), Some("08006"));
391
392        let err = Error::Closed {
393            message: "closed".to_string(),
394            sqlstate: Some("08003".to_string()),
395        };
396        assert_eq!(err.sqlstate(), Some("08003"));
397    }
398
399    /// `Display` appends `DETAIL` only when the message doesn't already
400    /// carry it — gRPC folds the detail into the message *and* reports it
401    /// separately, and printing it twice was the bug this guard prevents.
402    #[test]
403    fn test_display_detail_suffix() {
404        let err = Error::Query {
405            message: "column not found".to_string(),
406            sqlstate: None,
407            detail: Some("column \"foo\" does not exist".to_string()),
408            hint: None,
409        };
410        assert_eq!(
411            err.to_string(),
412            "column not found: column \"foo\" does not exist"
413        );
414
415        let err = Error::Query {
416            message: "column not found: column \"foo\" does not exist".to_string(),
417            sqlstate: None,
418            detail: Some("column \"foo\" does not exist".to_string()),
419            hint: None,
420        };
421        assert_eq!(
422            err.to_string(),
423            "column not found: column \"foo\" does not exist",
424            "detail already present in the message must not be repeated"
425        );
426    }
427
428    /// An `io::Error` renders exactly once. The previous struct-shaped
429    /// error stored the same text as both `message` and `cause`, so
430    /// `Display` emitted it twice.
431    #[test]
432    fn test_io_error_renders_once() {
433        let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
434        let err = Error::from(io_err);
435        assert_eq!(err.to_string(), "refused");
436    }
437
438    #[test]
439    fn test_extract_sqlstate_edge_cases() {
440        // Valid SQLSTATE
441        assert_eq!(extract_sqlstate("ERROR: message (42P04)"), Some("42P04"));
442
443        // With spaces
444        assert_eq!(extract_sqlstate("ERROR: message ( 42P04 )"), Some("42P04"));
445
446        // Multiple parentheses (should extract last one)
447        assert_eq!(
448            extract_sqlstate("ERROR: (extra info) message (42P04)"),
449            Some("42P04")
450        );
451
452        // Invalid: too short
453        assert_eq!(extract_sqlstate("ERROR: message (42P)"), None);
454
455        // Invalid: too long
456        assert_eq!(extract_sqlstate("ERROR: message (42P044)"), None);
457
458        // Invalid: non-alphanumeric
459        assert_eq!(extract_sqlstate("ERROR: message (42-04)"), None);
460
461        // No parentheses
462        assert_eq!(extract_sqlstate("ERROR: message"), None);
463
464        // Empty parentheses
465        assert_eq!(extract_sqlstate("ERROR: message ()"), None);
466    }
467}