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}