Skip to main content

exoware_sdk/proto/
error_details.rs

1use base64::Engine;
2use buffa::Message;
3use buffa_types::google::protobuf::Any;
4use connectrpc::error::ErrorDetail;
5use connectrpc::{ConnectError, ErrorCode};
6
7use crate::google::rpc::{BadRequest, ErrorInfo, RetryInfo};
8use crate::query::Detail;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct DecodedConnectError {
12    pub code: ErrorCode,
13    pub message: Option<String>,
14    pub bad_request: Option<BadRequest>,
15    pub error_info: Option<ErrorInfo>,
16    pub retry_info: Option<RetryInfo>,
17    pub query_detail: Option<Detail>,
18    pub other_details: Vec<Any>,
19}
20
21pub fn with_bad_request_detail(err: ConnectError, detail: BadRequest) -> ConnectError {
22    err.with_detail(pack_detail(&detail, BadRequest::TYPE_URL))
23}
24
25pub fn with_error_info_detail(err: ConnectError, detail: ErrorInfo) -> ConnectError {
26    err.with_detail(pack_detail(&detail, ErrorInfo::TYPE_URL))
27}
28
29pub fn with_retry_info_detail(err: ConnectError, detail: RetryInfo) -> ConnectError {
30    err.with_detail(pack_detail(&detail, RetryInfo::TYPE_URL))
31}
32
33/// Attaches [`Detail`] (`store.query.v1.Detail`) for query RPC error responses.
34pub fn with_query_detail(err: ConnectError, detail: Detail) -> ConnectError {
35    err.with_detail(pack_detail(&detail, Detail::TYPE_URL))
36}
37
38pub fn decode_connect_error(err: &ConnectError) -> Result<DecodedConnectError, buffa::DecodeError> {
39    let mut decoded = DecodedConnectError {
40        code: err.code,
41        message: err.message.clone(),
42        bad_request: None,
43        error_info: None,
44        retry_info: None,
45        query_detail: None,
46        other_details: Vec::new(),
47    };
48
49    for detail in &err.details {
50        let any = decode_detail(detail)?;
51        if let Some(msg) = any.unpack_if::<BadRequest>(BadRequest::TYPE_URL)? {
52            decoded.bad_request = Some(msg);
53            continue;
54        }
55        if let Some(msg) = any.unpack_if::<ErrorInfo>(ErrorInfo::TYPE_URL)? {
56            decoded.error_info = Some(msg);
57            continue;
58        }
59        if let Some(msg) = any.unpack_if::<RetryInfo>(RetryInfo::TYPE_URL)? {
60            decoded.retry_info = Some(msg);
61            continue;
62        }
63        if let Some(msg) = any.unpack_if::<Detail>(Detail::TYPE_URL)? {
64            decoded.query_detail = Some(msg);
65            continue;
66        }
67        decoded.other_details.push(any);
68    }
69
70    Ok(decoded)
71}
72
73fn pack_detail<M: Message>(message: &M, type_url: &str) -> ErrorDetail {
74    let any = Any::pack(message, type_url.to_string());
75    ErrorDetail {
76        type_url: any.type_url,
77        value: Some(base64::engine::general_purpose::STANDARD_NO_PAD.encode(any.value)),
78        debug: None,
79    }
80}
81
82fn decode_detail(detail: &ErrorDetail) -> Result<Any, buffa::DecodeError> {
83    // InvalidUtf8 is the closest available variant for base64 decode failures
84    // (buffa::DecodeError has no generic/catch-all variant).
85    let value = detail
86        .value
87        .as_deref()
88        .map(|encoded| {
89            base64::engine::general_purpose::STANDARD_NO_PAD
90                .decode(encoded)
91                .or_else(|_| base64::engine::general_purpose::STANDARD.decode(encoded))
92        })
93        .transpose()
94        .map_err(|_| buffa::DecodeError::InvalidUtf8)?
95        .unwrap_or_default();
96    Ok(Any {
97        type_url: detail.type_url.clone(),
98        value,
99        ..Default::default()
100    })
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::google::rpc::{bad_request::FieldViolation, ErrorInfo};
107    use crate::query::Detail;
108
109    #[test]
110    fn round_trips_read_stats_detail() {
111        let err = with_query_detail(
112            ConnectError::invalid_argument("bad"),
113            Detail {
114                sequence_number: 42,
115                ..Default::default()
116            },
117        );
118        let decoded = decode_connect_error(&err).expect("decode details");
119        assert_eq!(decoded.query_detail.unwrap().sequence_number, 42);
120    }
121
122    #[test]
123    fn round_trips_typed_details() {
124        let err = with_error_info_detail(
125            with_bad_request_detail(
126                ConnectError::invalid_argument("invalid request"),
127                BadRequest {
128                    field_violations: vec![FieldViolation {
129                        field: "key".to_string(),
130                        description: "too long".to_string(),
131                        ..Default::default()
132                    }],
133                    ..Default::default()
134                },
135            ),
136            ErrorInfo {
137                reason: "INVALID_KEY".to_string(),
138                domain: "store.ingest".to_string(),
139                metadata: [("max_key_len".to_string(), "254".to_string())]
140                    .into_iter()
141                    .collect(),
142                ..Default::default()
143            },
144        );
145
146        let decoded = decode_connect_error(&err).expect("decode details");
147        assert_eq!(decoded.code, ErrorCode::InvalidArgument);
148        assert_eq!(
149            decoded
150                .bad_request
151                .unwrap()
152                .field_violations
153                .first()
154                .expect("field violation")
155                .field,
156            "key"
157        );
158        assert_eq!(
159            decoded
160                .error_info
161                .unwrap()
162                .metadata
163                .get("max_key_len")
164                .map(String::as_str),
165            Some("254")
166        );
167    }
168}