hyperdb_api_core/client/grpc/
error.rs1use std::fmt;
10
11use tonic::Status;
12
13use crate::client::error::Error;
14
15#[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 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
74fn 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#[derive(Debug, Clone)]
89pub struct GrpcError {
90 pub sqlstate: Option<String>,
92 pub message: String,
94 pub detail: Option<String>,
96 pub hint: Option<String>,
98 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)]
118pub(super) fn from_grpc_status(status: Status) -> Error {
124 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 if let Some(error) = parse_xml_error(status.message()) {
136 return error;
137 }
138
139 grpc_code_to_variant(status.code()).build(status.message().to_string(), None, None, None)
141}
142
143fn parse_error_info(status: &Status) -> Option<GrpcError> {
145 let details = status.details();
153 if details.is_empty() {
154 return None;
155 }
156
157 parse_error_info_from_bytes(details)
160}
161
162fn parse_error_info_from_bytes(data: &[u8]) -> Option<GrpcError> {
167 use prost::Message;
169
170 #[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 if detail
186 .type_url
187 .ends_with("salesforce.hyperdb.grpc.v1.ErrorInfo")
188 {
189 if let Some(error_info) = decode_error_info(&detail.value) {
191 return Some(error_info);
192 }
193 }
194 }
195 }
196
197 None
198}
199
200fn decode_error_info(data: &[u8]) -> Option<GrpcError> {
202 use prost::Message;
203
204 #[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 #[prost(string, tag = "7")]
226 error_source: String,
227 }
228
229 if let Ok(info) = ErrorInfo::decode(data) {
230 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
265fn parse_xml_error(message: &str) -> Option<Error> {
269 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 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 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
295fn 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
306fn grpc_code_to_variant(code: tonic::Code) -> Variant {
308 match code {
309 tonic::Code::Ok => Variant::Other, 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
329fn sqlstate_to_variant(sqlstate: &str) -> Variant {
331 match sqlstate {
332 "57014" => Variant::Cancelled,
334 s if s.starts_with("28") => Variant::Authentication,
336 s if s.starts_with("08") => Variant::Connection,
338 "0A000" => Variant::FeatureNotSupported,
340 _ => 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 #[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 #[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 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}