hyperdb_api_core/client/grpc/
error.rs1use std::fmt;
10
11use tonic::Status;
12
13use crate::client::error::{Error, ErrorKind};
14
15#[derive(Debug, Clone)]
20pub struct GrpcError {
21 pub sqlstate: Option<String>,
23 pub message: String,
25 pub detail: Option<String>,
27 pub hint: Option<String>,
29 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)]
49pub(super) fn from_grpc_status(status: Status) -> Error {
55 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 if let Some(error) = parse_xml_error(status.message()) {
68 return error;
69 }
70
71 Error::new(grpc_code_to_error_kind(status.code()), status.message())
73}
74
75fn parse_error_info(status: &Status) -> Option<GrpcError> {
77 let details = status.details();
85 if details.is_empty() {
86 return None;
87 }
88
89 parse_error_info_from_bytes(details)
92}
93
94fn parse_error_info_from_bytes(data: &[u8]) -> Option<GrpcError> {
99 use prost::Message;
101
102 #[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 if detail
118 .type_url
119 .ends_with("salesforce.hyperdb.grpc.v1.ErrorInfo")
120 {
121 if let Some(error_info) = decode_error_info(&detail.value) {
123 return Some(error_info);
124 }
125 }
126 }
127 }
128
129 None
130}
131
132fn decode_error_info(data: &[u8]) -> Option<GrpcError> {
134 use prost::Message;
135
136 #[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 #[prost(string, tag = "7")]
158 error_source: String,
159 }
160
161 if let Ok(info) = ErrorInfo::decode(data) {
162 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
197fn parse_xml_error(message: &str) -> Option<Error> {
201 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 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 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
233fn 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
244fn grpc_code_to_error_kind(code: tonic::Code) -> ErrorKind {
246 match code {
247 tonic::Code::Ok => ErrorKind::Other, 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
267fn sqlstate_to_error_kind(sqlstate: &str) -> ErrorKind {
269 match sqlstate {
270 "57014" => ErrorKind::Cancelled,
272 s if s.starts_with("28") => ErrorKind::Authentication,
274 s if s.starts_with("08") => ErrorKind::Connection,
276 "0A000" => ErrorKind::FeatureNotSupported,
278 _ => 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}