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, ErrorKind};
14
15/// gRPC-specific error information.
16///
17/// This wraps additional error details from Hyper's gRPC error responses,
18/// including SQLSTATE codes, hints, and detailed error messages.
19#[derive(Debug, Clone)]
20pub struct GrpcError {
21    /// The SQLSTATE error code (e.g., "42703" for undefined column)
22    pub sqlstate: Option<String>,
23    /// The primary error message
24    pub message: String,
25    /// Additional detail about the error
26    pub detail: Option<String>,
27    /// A hint for how to resolve the error
28    pub hint: Option<String>,
29    /// The error source ("User" or "System")
30    pub error_source: Option<String>,
31}
32
33impl fmt::Display for GrpcError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        write!(f, "{}", self.message)?;
36        if let Some(ref detail) = self.detail {
37            write!(f, ": {detail}")?;
38        }
39        Ok(())
40    }
41}
42
43impl std::error::Error for GrpcError {}
44
45#[expect(
46    clippy::needless_pass_by_value,
47    reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
48)]
49/// Converts a tonic gRPC Status to our Error type.
50///
51/// This function attempts to parse Hyper's structured error details from the
52/// gRPC status. If that fails, it falls back to parsing XML error format,
53/// and finally to using the raw gRPC error message.
54pub(super) fn from_grpc_status(status: Status) -> Error {
55    // First, try to parse structured error details (ErrorInfo proto)
56    if let Some(error_info) = parse_error_info(&status) {
57        return Error::new_with_details(
58            grpc_code_to_error_kind(status.code()),
59            error_info.message,
60            error_info.detail,
61            error_info.hint,
62            error_info.sqlstate,
63        );
64    }
65
66    // Fall back to parsing XML error format from the message
67    if let Some(error) = parse_xml_error(status.message()) {
68        return error;
69    }
70
71    // Last resort: use the raw gRPC error message
72    Error::new(grpc_code_to_error_kind(status.code()), status.message())
73}
74
75/// Attempts to parse `ErrorInfo` from the gRPC status details.
76fn parse_error_info(status: &Status) -> Option<GrpcError> {
77    // The error details are in the status metadata as a serialized google.rpc.Status
78    // containing salesforce.hyperdb.grpc.v1.ErrorInfo
79    //
80    // For now, we'll implement a simplified version that extracts from the status
81    // details bytes. A full implementation would use prost to decode the Any types.
82
83    // Try to decode the details
84    let details = status.details();
85    if details.is_empty() {
86        return None;
87    }
88
89    // Try to parse as google.rpc.Status containing ErrorInfo
90    // This is a simplified implementation - we look for known field patterns
91    parse_error_info_from_bytes(details)
92}
93
94/// Parses `ErrorInfo` from raw bytes.
95///
96/// This is a simplified parser that looks for the `ErrorInfo` fields in the
97/// serialized protobuf data.
98fn parse_error_info_from_bytes(data: &[u8]) -> Option<GrpcError> {
99    // Try to decode using prost
100    use prost::Message;
101
102    // The details are wrapped in google.rpc.Status
103    // which contains a repeated Any field with ErrorInfo
104    #[derive(Clone, PartialEq, Message)]
105    struct GoogleRpcStatus {
106        #[prost(int32, tag = "1")]
107        code: i32,
108        #[prost(string, tag = "2")]
109        message: String,
110        #[prost(message, repeated, tag = "3")]
111        details: Vec<prost_types::Any>,
112    }
113
114    if let Ok(rpc_status) = GoogleRpcStatus::decode(data) {
115        for detail in rpc_status.details {
116            // Check if this is an ErrorInfo
117            if detail
118                .type_url
119                .ends_with("salesforce.hyperdb.grpc.v1.ErrorInfo")
120            {
121                // Try to decode the ErrorInfo
122                if let Some(error_info) = decode_error_info(&detail.value) {
123                    return Some(error_info);
124                }
125            }
126        }
127    }
128
129    None
130}
131
132/// Decodes `ErrorInfo` from its serialized form.
133fn decode_error_info(data: &[u8]) -> Option<GrpcError> {
134    use prost::Message;
135
136    // ErrorInfo proto fields:
137    // 1: primary_message (string)
138    // 2: sqlstate (string)
139    // 3: customer_hint (string)
140    // 4: customer_detail (string)
141    // 5: system_detail (string)
142    // 6: position (message)
143    // 7: error_source (string)
144    #[derive(Clone, PartialEq, Message)]
145    struct ErrorInfo {
146        #[prost(string, tag = "1")]
147        primary_message: String,
148        #[prost(string, tag = "2")]
149        sqlstate: String,
150        #[prost(string, tag = "3")]
151        customer_hint: String,
152        #[prost(string, tag = "4")]
153        customer_detail: String,
154        #[prost(string, tag = "5")]
155        system_detail: String,
156        // Skipping position (tag 6) for now
157        #[prost(string, tag = "7")]
158        error_source: String,
159    }
160
161    if let Ok(info) = ErrorInfo::decode(data) {
162        // Build error message combining primary_message and customer_detail
163        let message = if info.customer_detail.is_empty() {
164            info.primary_message.clone()
165        } else {
166            format!("{}: {}", info.primary_message, info.customer_detail)
167        };
168
169        return Some(GrpcError {
170            sqlstate: if info.sqlstate.is_empty() {
171                None
172            } else {
173                Some(info.sqlstate)
174            },
175            message,
176            detail: if info.customer_detail.is_empty() {
177                None
178            } else {
179                Some(info.customer_detail)
180            },
181            hint: if info.customer_hint.is_empty() {
182                None
183            } else {
184                Some(info.customer_hint)
185            },
186            error_source: if info.error_source.is_empty() {
187                None
188            } else {
189                Some(info.error_source)
190            },
191        });
192    }
193
194    None
195}
196
197/// Parses XML-format error message (legacy Hyper error format).
198///
199/// Example: `<sqlstate>42703</sqlstate><primary>column not found</primary><detail>...</detail>`
200fn parse_xml_error(message: &str) -> Option<Error> {
201    // Quick check if this looks like XML
202    if !message.contains("<sqlstate>") && !message.contains("<primary>") {
203        return None;
204    }
205
206    let sqlstate = extract_xml_tag(message, "sqlstate");
207    let primary = extract_xml_tag(message, "primary");
208    let detail = extract_xml_tag(message, "detail");
209    let hint = extract_xml_tag(message, "hint");
210
211    // Build the error message
212    let error_message = match (&primary, &detail) {
213        (Some(p), Some(d)) => format!("{p}: {d}"),
214        (Some(p), None) => p.clone(),
215        (None, Some(d)) => d.clone(),
216        (None, None) => message.to_string(),
217    };
218
219    // Determine error kind from SQLSTATE
220    let kind = sqlstate
221        .as_ref()
222        .map_or(ErrorKind::Query, |s| sqlstate_to_error_kind(s));
223
224    Some(Error::new_with_details(
225        kind,
226        error_message,
227        detail,
228        hint,
229        sqlstate,
230    ))
231}
232
233/// Extracts content from an XML tag like `<tag>content</tag>`.
234fn extract_xml_tag(text: &str, tag: &str) -> Option<String> {
235    let start_tag = format!("<{tag}>");
236    let end_tag = format!("</{tag}>");
237
238    let start = text.find(&start_tag)? + start_tag.len();
239    let end = text[start..].find(&end_tag)? + start;
240
241    Some(text[start..end].to_string())
242}
243
244/// Converts gRPC status code to `ErrorKind`.
245fn grpc_code_to_error_kind(code: tonic::Code) -> ErrorKind {
246    match code {
247        tonic::Code::Ok => ErrorKind::Other, // Shouldn't happen for errors
248        tonic::Code::Cancelled => ErrorKind::Cancelled,
249        tonic::Code::Unknown => ErrorKind::Query,
250        tonic::Code::InvalidArgument => ErrorKind::Query,
251        tonic::Code::DeadlineExceeded => ErrorKind::Timeout,
252        tonic::Code::NotFound => ErrorKind::Query,
253        tonic::Code::AlreadyExists => ErrorKind::Query,
254        tonic::Code::PermissionDenied => ErrorKind::Authentication,
255        tonic::Code::ResourceExhausted => ErrorKind::Query,
256        tonic::Code::FailedPrecondition => ErrorKind::Query,
257        tonic::Code::Aborted => ErrorKind::Query,
258        tonic::Code::OutOfRange => ErrorKind::Query,
259        tonic::Code::Unimplemented => ErrorKind::FeatureNotSupported,
260        tonic::Code::Internal => ErrorKind::Query,
261        tonic::Code::Unavailable => ErrorKind::Connection,
262        tonic::Code::DataLoss => ErrorKind::Query,
263        tonic::Code::Unauthenticated => ErrorKind::Authentication,
264    }
265}
266
267/// Converts SQLSTATE code to `ErrorKind`.
268fn sqlstate_to_error_kind(sqlstate: &str) -> ErrorKind {
269    match sqlstate {
270        // Query canceled
271        "57014" => ErrorKind::Cancelled,
272        // Authentication errors (28xxx)
273        s if s.starts_with("28") => ErrorKind::Authentication,
274        // Connection errors (08xxx)
275        s if s.starts_with("08") => ErrorKind::Connection,
276        // Feature not supported (0A000)
277        "0A000" => ErrorKind::FeatureNotSupported,
278        // Everything else is a query error
279        _ => ErrorKind::Query,
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_parse_xml_error() {
289        let msg = "<sqlstate>42703</sqlstate><primary>column not found</primary><detail>column \"foo\" does not exist</detail>";
290        let error = parse_xml_error(msg).unwrap();
291        assert!(error.to_string().contains("column not found"));
292    }
293
294    #[test]
295    fn test_extract_xml_tag() {
296        assert_eq!(
297            extract_xml_tag("<foo>bar</foo>", "foo"),
298            Some("bar".to_string())
299        );
300        assert_eq!(
301            extract_xml_tag("<a>1</a><b>2</b>", "b"),
302            Some("2".to_string())
303        );
304        assert_eq!(extract_xml_tag("<a>1</a>", "c"), None);
305    }
306
307    #[test]
308    fn test_grpc_code_mapping() {
309        assert!(matches!(
310            grpc_code_to_error_kind(tonic::Code::Cancelled),
311            ErrorKind::Cancelled
312        ));
313        assert!(matches!(
314            grpc_code_to_error_kind(tonic::Code::Unauthenticated),
315            ErrorKind::Authentication
316        ));
317        assert!(matches!(
318            grpc_code_to_error_kind(tonic::Code::Unavailable),
319            ErrorKind::Connection
320        ));
321    }
322}