use std::fmt;
use tonic::Status;
use crate::client::error::{Error, ErrorKind};
#[derive(Debug, Clone)]
pub struct GrpcError {
pub sqlstate: Option<String>,
pub message: String,
pub detail: Option<String>,
pub hint: Option<String>,
pub error_source: Option<String>,
}
impl fmt::Display for GrpcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)?;
if let Some(ref detail) = self.detail {
write!(f, ": {detail}")?;
}
Ok(())
}
}
impl std::error::Error for GrpcError {}
#[expect(
clippy::needless_pass_by_value,
reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
)]
pub(super) fn from_grpc_status(status: Status) -> Error {
if let Some(error_info) = parse_error_info(&status) {
return Error::new_with_details(
grpc_code_to_error_kind(status.code()),
error_info.message,
error_info.detail,
error_info.hint,
error_info.sqlstate,
);
}
if let Some(error) = parse_xml_error(status.message()) {
return error;
}
Error::new(grpc_code_to_error_kind(status.code()), status.message())
}
fn parse_error_info(status: &Status) -> Option<GrpcError> {
let details = status.details();
if details.is_empty() {
return None;
}
parse_error_info_from_bytes(details)
}
fn parse_error_info_from_bytes(data: &[u8]) -> Option<GrpcError> {
use prost::Message;
#[derive(Clone, PartialEq, Message)]
struct GoogleRpcStatus {
#[prost(int32, tag = "1")]
code: i32,
#[prost(string, tag = "2")]
message: String,
#[prost(message, repeated, tag = "3")]
details: Vec<prost_types::Any>,
}
if let Ok(rpc_status) = GoogleRpcStatus::decode(data) {
for detail in rpc_status.details {
if detail
.type_url
.ends_with("salesforce.hyperdb.grpc.v1.ErrorInfo")
{
if let Some(error_info) = decode_error_info(&detail.value) {
return Some(error_info);
}
}
}
}
None
}
fn decode_error_info(data: &[u8]) -> Option<GrpcError> {
use prost::Message;
#[derive(Clone, PartialEq, Message)]
struct ErrorInfo {
#[prost(string, tag = "1")]
primary_message: String,
#[prost(string, tag = "2")]
sqlstate: String,
#[prost(string, tag = "3")]
customer_hint: String,
#[prost(string, tag = "4")]
customer_detail: String,
#[prost(string, tag = "5")]
system_detail: String,
#[prost(string, tag = "7")]
error_source: String,
}
if let Ok(info) = ErrorInfo::decode(data) {
let message = if info.customer_detail.is_empty() {
info.primary_message.clone()
} else {
format!("{}: {}", info.primary_message, info.customer_detail)
};
return Some(GrpcError {
sqlstate: if info.sqlstate.is_empty() {
None
} else {
Some(info.sqlstate)
},
message,
detail: if info.customer_detail.is_empty() {
None
} else {
Some(info.customer_detail)
},
hint: if info.customer_hint.is_empty() {
None
} else {
Some(info.customer_hint)
},
error_source: if info.error_source.is_empty() {
None
} else {
Some(info.error_source)
},
});
}
None
}
fn parse_xml_error(message: &str) -> Option<Error> {
if !message.contains("<sqlstate>") && !message.contains("<primary>") {
return None;
}
let sqlstate = extract_xml_tag(message, "sqlstate");
let primary = extract_xml_tag(message, "primary");
let detail = extract_xml_tag(message, "detail");
let hint = extract_xml_tag(message, "hint");
let error_message = match (&primary, &detail) {
(Some(p), Some(d)) => format!("{p}: {d}"),
(Some(p), None) => p.clone(),
(None, Some(d)) => d.clone(),
(None, None) => message.to_string(),
};
let kind = sqlstate
.as_ref()
.map_or(ErrorKind::Query, |s| sqlstate_to_error_kind(s));
Some(Error::new_with_details(
kind,
error_message,
detail,
hint,
sqlstate,
))
}
fn extract_xml_tag(text: &str, tag: &str) -> Option<String> {
let start_tag = format!("<{tag}>");
let end_tag = format!("</{tag}>");
let start = text.find(&start_tag)? + start_tag.len();
let end = text[start..].find(&end_tag)? + start;
Some(text[start..end].to_string())
}
fn grpc_code_to_error_kind(code: tonic::Code) -> ErrorKind {
match code {
tonic::Code::Ok => ErrorKind::Other, tonic::Code::Cancelled => ErrorKind::Cancelled,
tonic::Code::Unknown => ErrorKind::Query,
tonic::Code::InvalidArgument => ErrorKind::Query,
tonic::Code::DeadlineExceeded => ErrorKind::Timeout,
tonic::Code::NotFound => ErrorKind::Query,
tonic::Code::AlreadyExists => ErrorKind::Query,
tonic::Code::PermissionDenied => ErrorKind::Authentication,
tonic::Code::ResourceExhausted => ErrorKind::Query,
tonic::Code::FailedPrecondition => ErrorKind::Query,
tonic::Code::Aborted => ErrorKind::Query,
tonic::Code::OutOfRange => ErrorKind::Query,
tonic::Code::Unimplemented => ErrorKind::FeatureNotSupported,
tonic::Code::Internal => ErrorKind::Query,
tonic::Code::Unavailable => ErrorKind::Connection,
tonic::Code::DataLoss => ErrorKind::Query,
tonic::Code::Unauthenticated => ErrorKind::Authentication,
}
}
fn sqlstate_to_error_kind(sqlstate: &str) -> ErrorKind {
match sqlstate {
"57014" => ErrorKind::Cancelled,
s if s.starts_with("28") => ErrorKind::Authentication,
s if s.starts_with("08") => ErrorKind::Connection,
"0A000" => ErrorKind::FeatureNotSupported,
_ => ErrorKind::Query,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_xml_error() {
let msg = "<sqlstate>42703</sqlstate><primary>column not found</primary><detail>column \"foo\" does not exist</detail>";
let error = parse_xml_error(msg).unwrap();
assert!(error.to_string().contains("column not found"));
}
#[test]
fn test_extract_xml_tag() {
assert_eq!(
extract_xml_tag("<foo>bar</foo>", "foo"),
Some("bar".to_string())
);
assert_eq!(
extract_xml_tag("<a>1</a><b>2</b>", "b"),
Some("2".to_string())
);
assert_eq!(extract_xml_tag("<a>1</a>", "c"), None);
}
#[test]
fn test_grpc_code_mapping() {
assert!(matches!(
grpc_code_to_error_kind(tonic::Code::Cancelled),
ErrorKind::Cancelled
));
assert!(matches!(
grpc_code_to_error_kind(tonic::Code::Unauthenticated),
ErrorKind::Authentication
));
assert!(matches!(
grpc_code_to_error_kind(tonic::Code::Unavailable),
ErrorKind::Connection
));
}
}