Skip to main content

hyperdb_api_core/client/grpc/
error.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! gRPC-specific error types.
5//!
6//! This module handles conversion from gRPC status codes and Hyper's structured
7//! error details to the common [`crate::Error`] type.
8
9use std::fmt;
10
11use tonic::Status;
12
13use crate::client::error::Error;
14
15/// Which [`Error`] variant a gRPC status code or SQLSTATE maps to.
16///
17/// gRPC is the one place in the crate where the variant is chosen at
18/// runtime from a wire code rather than being known at the call site, so
19/// the decision needs a name it can be carried around under. It stays
20/// private to this module — [`Error`] itself is flat and has no `kind`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum Variant {
23    Authentication,
24    Cancelled,
25    Connection,
26    FeatureNotSupported,
27    Other,
28    Query,
29    Timeout,
30}
31
32impl Variant {
33    /// Builds the corresponding [`Error`], attaching the server's
34    /// diagnostics to the variants that carry them.
35    ///
36    /// `Query`, `Connection`, and `Cancelled` have fields for the pieces
37    /// they can use. The rest have only a message, so any `detail` is
38    /// folded into it rather than dropped — that keeps the rendered text
39    /// identical to what the previous struct-shaped error produced.
40    fn build(
41        self,
42        message: String,
43        detail: Option<String>,
44        hint: Option<String>,
45        sqlstate: Option<String>,
46    ) -> Error {
47        match self {
48            Variant::Query => Error::Query {
49                message,
50                sqlstate,
51                detail,
52                hint,
53            },
54            Variant::Connection => Error::Connection {
55                message: fold_detail(message, detail.as_deref()),
56                sqlstate,
57            },
58            Variant::Cancelled => Error::Cancelled {
59                message: fold_detail(message, detail.as_deref()),
60                sqlstate,
61            },
62            Variant::Authentication => {
63                Error::authentication(fold_detail(message, detail.as_deref()))
64            }
65            Variant::FeatureNotSupported => {
66                Error::feature_not_supported(fold_detail(message, detail.as_deref()))
67            }
68            Variant::Timeout => Error::timeout(fold_detail(message, detail.as_deref())),
69            Variant::Other => Error::other(fold_detail(message, detail.as_deref())),
70        }
71    }
72}
73
74/// Appends `": {detail}"` unless `message` already contains it, matching
75/// what `Error`'s `Display` does for the variants that keep a `detail`
76/// field.
77fn fold_detail(message: String, detail: Option<&str>) -> String {
78    match detail {
79        Some(detail) if !message.contains(detail) => format!("{message}: {detail}"),
80        _ => message,
81    }
82}
83
84/// gRPC-specific error information.
85///
86/// This wraps additional error details from Hyper's gRPC error responses,
87/// including SQLSTATE codes, hints, and detailed error messages.
88#[derive(Debug, Clone)]
89pub struct GrpcError {
90    /// The SQLSTATE error code (e.g., "42703" for undefined column)
91    pub sqlstate: Option<String>,
92    /// The primary error message
93    pub message: String,
94    /// Additional detail about the error
95    pub detail: Option<String>,
96    /// A hint for how to resolve the error
97    pub hint: Option<String>,
98    /// The error source ("User" or "System")
99    pub error_source: Option<String>,
100}
101
102impl fmt::Display for GrpcError {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        write!(f, "{}", self.message)?;
105        if let Some(ref detail) = self.detail {
106            write!(f, ": {detail}")?;
107        }
108        Ok(())
109    }
110}
111
112impl std::error::Error for GrpcError {}
113
114#[expect(
115    clippy::needless_pass_by_value,
116    reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
117)]
118/// Converts a tonic gRPC Status to our Error type.
119///
120/// This function attempts to parse Hyper's structured error details from the
121/// gRPC status. If that fails, it falls back to parsing XML error format,
122/// and finally to using the raw gRPC error message.
123pub(super) fn from_grpc_status(status: Status) -> Error {
124    // First, try to parse structured error details (ErrorInfo proto)
125    if let Some(error_info) = parse_error_info(&status) {
126        return grpc_code_to_variant(status.code()).build(
127            error_info.message,
128            error_info.detail,
129            error_info.hint,
130            error_info.sqlstate,
131        );
132    }
133
134    // Fall back to parsing XML error format from the message
135    if let Some(error) = parse_xml_error(status.message()) {
136        return error;
137    }
138
139    // Last resort: use the raw gRPC error message
140    grpc_code_to_variant(status.code()).build(status.message().to_string(), None, None, None)
141}
142
143/// Attempts to parse `ErrorInfo` from the gRPC status details.
144fn parse_error_info(status: &Status) -> Option<GrpcError> {
145    // The error details are in the status metadata as a serialized google.rpc.Status
146    // containing salesforce.hyperdb.grpc.v1.ErrorInfo
147    //
148    // For now, we'll implement a simplified version that extracts from the status
149    // details bytes. A full implementation would use prost to decode the Any types.
150
151    // Try to decode the details
152    let details = status.details();
153    if details.is_empty() {
154        return None;
155    }
156
157    // Try to parse as google.rpc.Status containing ErrorInfo
158    // This is a simplified implementation - we look for known field patterns
159    parse_error_info_from_bytes(details)
160}
161
162/// Parses `ErrorInfo` from raw bytes.
163///
164/// This is a simplified parser that looks for the `ErrorInfo` fields in the
165/// serialized protobuf data.
166fn parse_error_info_from_bytes(data: &[u8]) -> Option<GrpcError> {
167    // Try to decode using prost
168    use prost::Message;
169
170    // The details are wrapped in google.rpc.Status
171    // which contains a repeated Any field with ErrorInfo
172    #[derive(Clone, PartialEq, Message)]
173    struct GoogleRpcStatus {
174        #[prost(int32, tag = "1")]
175        code: i32,
176        #[prost(string, tag = "2")]
177        message: String,
178        #[prost(message, repeated, tag = "3")]
179        details: Vec<prost_types::Any>,
180    }
181
182    if let Ok(rpc_status) = GoogleRpcStatus::decode(data) {
183        for detail in rpc_status.details {
184            // Check if this is an ErrorInfo
185            if detail
186                .type_url
187                .ends_with("salesforce.hyperdb.grpc.v1.ErrorInfo")
188            {
189                // Try to decode the ErrorInfo
190                if let Some(error_info) = decode_error_info(&detail.value) {
191                    return Some(error_info);
192                }
193            }
194        }
195    }
196
197    None
198}
199
200/// Decodes `ErrorInfo` from its serialized form.
201fn decode_error_info(data: &[u8]) -> Option<GrpcError> {
202    use prost::Message;
203
204    // ErrorInfo proto fields:
205    // 1: primary_message (string)
206    // 2: sqlstate (string)
207    // 3: customer_hint (string)
208    // 4: customer_detail (string)
209    // 5: system_detail (string)
210    // 6: position (message)
211    // 7: error_source (string)
212    #[derive(Clone, PartialEq, Message)]
213    struct ErrorInfo {
214        #[prost(string, tag = "1")]
215        primary_message: String,
216        #[prost(string, tag = "2")]
217        sqlstate: String,
218        #[prost(string, tag = "3")]
219        customer_hint: String,
220        #[prost(string, tag = "4")]
221        customer_detail: String,
222        #[prost(string, tag = "5")]
223        system_detail: String,
224        // Skipping position (tag 6) for now
225        #[prost(string, tag = "7")]
226        error_source: String,
227    }
228
229    if let Ok(info) = ErrorInfo::decode(data) {
230        // Build error message combining primary_message and customer_detail
231        let message = if info.customer_detail.is_empty() {
232            info.primary_message.clone()
233        } else {
234            format!("{}: {}", info.primary_message, info.customer_detail)
235        };
236
237        return Some(GrpcError {
238            sqlstate: if info.sqlstate.is_empty() {
239                None
240            } else {
241                Some(info.sqlstate)
242            },
243            message,
244            detail: if info.customer_detail.is_empty() {
245                None
246            } else {
247                Some(info.customer_detail)
248            },
249            hint: if info.customer_hint.is_empty() {
250                None
251            } else {
252                Some(info.customer_hint)
253            },
254            error_source: if info.error_source.is_empty() {
255                None
256            } else {
257                Some(info.error_source)
258            },
259        });
260    }
261
262    None
263}
264
265/// Parses XML-format error message (legacy Hyper error format).
266///
267/// Example: `<sqlstate>42703</sqlstate><primary>column not found</primary><detail>...</detail>`
268fn parse_xml_error(message: &str) -> Option<Error> {
269    // Quick check if this looks like XML
270    if !message.contains("<sqlstate>") && !message.contains("<primary>") {
271        return None;
272    }
273
274    let sqlstate = extract_xml_tag(message, "sqlstate");
275    let primary = extract_xml_tag(message, "primary");
276    let detail = extract_xml_tag(message, "detail");
277    let hint = extract_xml_tag(message, "hint");
278
279    // Build the error message
280    let error_message = match (&primary, &detail) {
281        (Some(p), Some(d)) => format!("{p}: {d}"),
282        (Some(p), None) => p.clone(),
283        (None, Some(d)) => d.clone(),
284        (None, None) => message.to_string(),
285    };
286
287    // Determine the variant from SQLSTATE
288    let variant = sqlstate
289        .as_ref()
290        .map_or(Variant::Query, |s| sqlstate_to_variant(s));
291
292    Some(variant.build(error_message, detail, hint, sqlstate))
293}
294
295/// Extracts content from an XML tag like `<tag>content</tag>`.
296fn extract_xml_tag(text: &str, tag: &str) -> Option<String> {
297    let start_tag = format!("<{tag}>");
298    let end_tag = format!("</{tag}>");
299
300    let start = text.find(&start_tag)? + start_tag.len();
301    let end = text[start..].find(&end_tag)? + start;
302
303    Some(text[start..end].to_string())
304}
305
306/// Converts a gRPC status code to the [`Error`] variant it maps to.
307fn grpc_code_to_variant(code: tonic::Code) -> Variant {
308    match code {
309        tonic::Code::Ok => Variant::Other, // Shouldn't happen for errors
310        tonic::Code::Cancelled => Variant::Cancelled,
311        tonic::Code::Unknown => Variant::Query,
312        tonic::Code::InvalidArgument => Variant::Query,
313        tonic::Code::DeadlineExceeded => Variant::Timeout,
314        tonic::Code::NotFound => Variant::Query,
315        tonic::Code::AlreadyExists => Variant::Query,
316        tonic::Code::PermissionDenied => Variant::Authentication,
317        tonic::Code::ResourceExhausted => Variant::Query,
318        tonic::Code::FailedPrecondition => Variant::Query,
319        tonic::Code::Aborted => Variant::Query,
320        tonic::Code::OutOfRange => Variant::Query,
321        tonic::Code::Unimplemented => Variant::FeatureNotSupported,
322        tonic::Code::Internal => Variant::Query,
323        tonic::Code::Unavailable => Variant::Connection,
324        tonic::Code::DataLoss => Variant::Query,
325        tonic::Code::Unauthenticated => Variant::Authentication,
326    }
327}
328
329/// Converts a SQLSTATE code to the [`Error`] variant it maps to.
330fn sqlstate_to_variant(sqlstate: &str) -> Variant {
331    match sqlstate {
332        // Query canceled
333        "57014" => Variant::Cancelled,
334        // Authentication errors (28xxx)
335        s if s.starts_with("28") => Variant::Authentication,
336        // Connection errors (08xxx)
337        s if s.starts_with("08") => Variant::Connection,
338        // Feature not supported (0A000)
339        "0A000" => Variant::FeatureNotSupported,
340        // Everything else is a query error
341        _ => Variant::Query,
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_parse_xml_error() {
351        let msg = "<sqlstate>42703</sqlstate><primary>column not found</primary><detail>column \"foo\" does not exist</detail>";
352        let error = parse_xml_error(msg).unwrap();
353        assert!(error.to_string().contains("column not found"));
354    }
355
356    #[test]
357    fn test_extract_xml_tag() {
358        assert_eq!(
359            extract_xml_tag("<foo>bar</foo>", "foo"),
360            Some("bar".to_string())
361        );
362        assert_eq!(
363            extract_xml_tag("<a>1</a><b>2</b>", "b"),
364            Some("2".to_string())
365        );
366        assert_eq!(extract_xml_tag("<a>1</a>", "c"), None);
367    }
368
369    #[test]
370    fn test_grpc_code_mapping() {
371        assert_eq!(
372            grpc_code_to_variant(tonic::Code::Cancelled),
373            Variant::Cancelled
374        );
375        assert_eq!(
376            grpc_code_to_variant(tonic::Code::Unauthenticated),
377            Variant::Authentication
378        );
379        assert_eq!(
380            grpc_code_to_variant(tonic::Code::Unavailable),
381            Variant::Connection
382        );
383    }
384
385    /// A SQLSTATE-selected variant must keep the code on the variants that
386    /// have a field for it — the public `hyperdb_api::Error` mapping reads
387    /// `sqlstate()` for `Cancelled` and `Connection`, not just `Query`.
388    #[test]
389    fn test_sqlstate_survives_variant_selection() {
390        let err = sqlstate_to_variant("57014").build(
391            "canceled".to_string(),
392            None,
393            None,
394            Some("57014".to_string()),
395        );
396        assert!(matches!(err, Error::Cancelled { .. }));
397        assert_eq!(err.sqlstate(), Some("57014"));
398
399        let err = sqlstate_to_variant("08006").build(
400            "connection failure".to_string(),
401            None,
402            None,
403            Some("08006".to_string()),
404        );
405        assert!(matches!(err, Error::Connection { .. }));
406        assert_eq!(err.sqlstate(), Some("08006"));
407    }
408
409    /// The variants with no `detail` field must fold it into the message
410    /// rather than dropping it, so the rendered text is unchanged.
411    #[test]
412    fn test_detail_folded_into_message_when_no_field() {
413        let err = Variant::Timeout.build(
414            "deadline exceeded".to_string(),
415            Some("waited 30s".to_string()),
416            None,
417            None,
418        );
419        assert_eq!(err.to_string(), "deadline exceeded: waited 30s");
420
421        // Already contained → not repeated.
422        let err = Variant::Timeout.build(
423            "deadline exceeded: waited 30s".to_string(),
424            Some("waited 30s".to_string()),
425            None,
426            None,
427        );
428        assert_eq!(err.to_string(), "deadline exceeded: waited 30s");
429    }
430}