use std::fmt;
use tonic::Status;
use crate::client::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Variant {
Authentication,
Cancelled,
Connection,
FeatureNotSupported,
Other,
Query,
Timeout,
}
impl Variant {
fn build(
self,
message: String,
detail: Option<String>,
hint: Option<String>,
sqlstate: Option<String>,
) -> Error {
match self {
Variant::Query => Error::Query {
message,
sqlstate,
detail,
hint,
},
Variant::Connection => Error::Connection {
message: fold_detail(message, detail.as_deref()),
sqlstate,
},
Variant::Cancelled => Error::Cancelled {
message: fold_detail(message, detail.as_deref()),
sqlstate,
},
Variant::Authentication => {
Error::authentication(fold_detail(message, detail.as_deref()))
}
Variant::FeatureNotSupported => {
Error::feature_not_supported(fold_detail(message, detail.as_deref()))
}
Variant::Timeout => Error::timeout(fold_detail(message, detail.as_deref())),
Variant::Other => Error::other(fold_detail(message, detail.as_deref())),
}
}
}
fn fold_detail(message: String, detail: Option<&str>) -> String {
match detail {
Some(detail) if !message.contains(detail) => format!("{message}: {detail}"),
_ => message,
}
}
#[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 grpc_code_to_variant(status.code()).build(
error_info.message,
error_info.detail,
error_info.hint,
error_info.sqlstate,
);
}
if let Some(error) = parse_xml_error(status.message()) {
return error;
}
grpc_code_to_variant(status.code()).build(status.message().to_string(), None, None, None)
}
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 variant = sqlstate
.as_ref()
.map_or(Variant::Query, |s| sqlstate_to_variant(s));
Some(variant.build(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_variant(code: tonic::Code) -> Variant {
match code {
tonic::Code::Ok => Variant::Other, tonic::Code::Cancelled => Variant::Cancelled,
tonic::Code::Unknown => Variant::Query,
tonic::Code::InvalidArgument => Variant::Query,
tonic::Code::DeadlineExceeded => Variant::Timeout,
tonic::Code::NotFound => Variant::Query,
tonic::Code::AlreadyExists => Variant::Query,
tonic::Code::PermissionDenied => Variant::Authentication,
tonic::Code::ResourceExhausted => Variant::Query,
tonic::Code::FailedPrecondition => Variant::Query,
tonic::Code::Aborted => Variant::Query,
tonic::Code::OutOfRange => Variant::Query,
tonic::Code::Unimplemented => Variant::FeatureNotSupported,
tonic::Code::Internal => Variant::Query,
tonic::Code::Unavailable => Variant::Connection,
tonic::Code::DataLoss => Variant::Query,
tonic::Code::Unauthenticated => Variant::Authentication,
}
}
fn sqlstate_to_variant(sqlstate: &str) -> Variant {
match sqlstate {
"57014" => Variant::Cancelled,
s if s.starts_with("28") => Variant::Authentication,
s if s.starts_with("08") => Variant::Connection,
"0A000" => Variant::FeatureNotSupported,
_ => Variant::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_eq!(
grpc_code_to_variant(tonic::Code::Cancelled),
Variant::Cancelled
);
assert_eq!(
grpc_code_to_variant(tonic::Code::Unauthenticated),
Variant::Authentication
);
assert_eq!(
grpc_code_to_variant(tonic::Code::Unavailable),
Variant::Connection
);
}
#[test]
fn test_sqlstate_survives_variant_selection() {
let err = sqlstate_to_variant("57014").build(
"canceled".to_string(),
None,
None,
Some("57014".to_string()),
);
assert!(matches!(err, Error::Cancelled { .. }));
assert_eq!(err.sqlstate(), Some("57014"));
let err = sqlstate_to_variant("08006").build(
"connection failure".to_string(),
None,
None,
Some("08006".to_string()),
);
assert!(matches!(err, Error::Connection { .. }));
assert_eq!(err.sqlstate(), Some("08006"));
}
#[test]
fn test_detail_folded_into_message_when_no_field() {
let err = Variant::Timeout.build(
"deadline exceeded".to_string(),
Some("waited 30s".to_string()),
None,
None,
);
assert_eq!(err.to_string(), "deadline exceeded: waited 30s");
let err = Variant::Timeout.build(
"deadline exceeded: waited 30s".to_string(),
Some("waited 30s".to_string()),
None,
None,
);
assert_eq!(err.to_string(), "deadline exceeded: waited 30s");
}
}