Skip to main content

origincloud_core/
error.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::time::Duration;
4
5use serde::Deserialize;
6
7use crate::rpc::{Any, Status};
8
9/// What a call, or waiting on an operation, failed with.
10#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum Error {
13    /// The API refused the call, or the operation failed: branch on
14    /// [`ApiError::reason`].
15    #[error(transparent)]
16    Api(Box<ApiError>),
17    /// The call got no answer: the connection failed or broke off.
18    #[error("origincloud: {0}")]
19    Transport(#[source] reqwest::Error),
20    /// Neither the environment nor a CLI session holds credentials.
21    #[error(
22        "origincloud: no credentials: set ORIGINCLOUD_CLIENT_ID and ORIGINCLOUD_CLIENT_SECRET, set ORIGINCLOUD_ACCESS_TOKEN, or log in with `origin id login`"
23    )]
24    NoCredentials,
25    /// The credentials couldn't supply an access token.
26    #[error("origincloud: {0}")]
27    Credentials(String),
28    /// The client's configuration is invalid.
29    #[error("origincloud: {0}")]
30    Config(String),
31    /// The request can't be sent as it is, such as a name of the wrong
32    /// form; it was not sent.
33    #[error("origincloud: {0}")]
34    InvalidRequest(String),
35    /// The answer couldn't be read.
36    #[error("origincloud: {0}")]
37    Decode(String),
38    /// [`Operation::result`](crate::Operation::result) of an operation that
39    /// isn't done.
40    #[error("origincloud: operation {0} isn't done")]
41    NotDone(String),
42}
43
44impl Error {
45    /// The refusal, when the API refused the call or the operation failed.
46    pub fn api(&self) -> Option<&ApiError> {
47        match self {
48            Self::Api(e) => Some(e),
49            _ => None,
50        }
51    }
52
53    /// The platform's error code, such as `NOT_FOUND`, when the API refused
54    /// the call with one.
55    pub fn reason(&self) -> Option<&str> {
56        self.api()
57            .map(|e| e.reason.as_str())
58            .filter(|r| !r.is_empty())
59    }
60
61    /// Whether the API answered that the same call may succeed if it is
62    /// sent again.
63    pub fn is_retryable(&self) -> bool {
64        self.api().is_some_and(ApiError::is_retryable)
65    }
66}
67
68impl From<ApiError> for Error {
69    fn from(e: ApiError) -> Self {
70        Self::Api(Box::new(e))
71    }
72}
73
74/// A call the API refused, or an operation that failed.
75#[derive(Clone, Debug, Default, PartialEq)]
76#[non_exhaustive]
77pub struct ApiError {
78    /// The response's HTTP status code; `None` for an operation's error.
79    pub http_status: Option<u16>,
80    /// The closest canonical code, such as `NOT_FOUND`, for generic
81    /// tooling.
82    pub status: String,
83    /// The platform's error code, such as `NOT_FOUND` or `CONFLICT`: what
84    /// to branch on.
85    pub reason: String,
86    /// More about the reason, by name, such as `serviceAccount`, the name
87    /// of the service account an earlier request with the same request id
88    /// created; empty when the API sends none.
89    pub metadata: HashMap<String, String>,
90    /// What went wrong, for a person.
91    pub message: String,
92    /// Identifies the request to Origin Cloud's support.
93    pub request_id: String,
94    /// The request fields that were refused.
95    pub field_violations: Vec<FieldViolation>,
96    /// How long the API asks to wait before trying again; `None` when it
97    /// doesn't ask.
98    pub retry_delay: Option<Duration>,
99    retryable: bool,
100}
101
102/// One request field the API refused.
103#[derive(Clone, Debug, Default, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct FieldViolation {
106    /// The field's JSON path, such as `volume.sizeGib`.
107    pub field: String,
108    /// Why it was refused.
109    pub description: String,
110}
111
112impl ApiError {
113    /// Whether the API answered that the same call may succeed if it is
114    /// sent again.
115    pub fn is_retryable(&self) -> bool {
116        self.retryable
117    }
118
119    /// The error a response with a status other than 200 carries. A
120    /// response that carries none, such as a proxy's, is an error by its
121    /// status alone, retryable when the status is.
122    pub(crate) fn from_response(http_status: u16, body: &[u8]) -> Self {
123        #[derive(Deserialize)]
124        struct Body {
125            error: Option<StatusBody>,
126        }
127        #[derive(Deserialize)]
128        #[serde(default)]
129        #[derive(Default)]
130        struct StatusBody {
131            status: String,
132            message: String,
133            details: Vec<serde_json::Value>,
134        }
135
136        let parsed = serde_json::from_slice::<Body>(body)
137            .ok()
138            .and_then(|b| b.error);
139        let Some(parsed) = parsed else {
140            let mut e = Self {
141                http_status: Some(http_status),
142                message: reqwest::StatusCode::from_u16(http_status)
143                    .ok()
144                    .and_then(|s| s.canonical_reason())
145                    .unwrap_or_default()
146                    .to_owned(),
147                ..Self::default()
148            };
149            if matches!(http_status, 502..=504) {
150                e.status = "UNAVAILABLE".to_owned();
151                e.retryable = true;
152            }
153            return e;
154        };
155        let mut e = Self {
156            http_status: Some(http_status),
157            status: parsed.status,
158            message: parsed.message,
159            ..Self::default()
160        };
161        // A detail that isn't a message is skipped, like one of an unknown
162        // type.
163        let details: Vec<Any> = parsed
164            .details
165            .into_iter()
166            .filter_map(|d| serde_json::from_value(d).ok())
167            .collect();
168        e.read_details(&details, true);
169        e
170    }
171
172    /// The error a failed operation carries.
173    pub(crate) fn from_status(status: &Status) -> Self {
174        let mut e = Self {
175            status: code_name(status.code).to_owned(),
176            message: status.message.clone(),
177            ..Self::default()
178        };
179        e.read_details(&status.details, false);
180        e
181    }
182
183    fn read_details(&mut self, details: &[Any], retry_info: bool) {
184        for detail in details {
185            match detail.type_name() {
186                "google.rpc.ErrorInfo" => {
187                    self.reason = detail.string("reason").to_owned();
188                    let metadata = detail
189                        .fields()
190                        .get("metadata")
191                        .and_then(serde_json::Value::as_object);
192                    self.metadata = metadata
193                        .into_iter()
194                        .flatten()
195                        .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned())))
196                        .collect();
197                }
198                "google.rpc.RequestInfo" => self.request_id = detail.string("requestId").to_owned(),
199                "google.rpc.RetryInfo" if retry_info => {
200                    self.retryable = true;
201                    self.retry_delay = parse_duration(detail.string("retryDelay"));
202                }
203                "google.rpc.BadRequest" => {
204                    let violations = detail
205                        .fields()
206                        .get("fieldViolations")
207                        .and_then(serde_json::Value::as_array);
208                    for v in violations.into_iter().flatten() {
209                        let text = |name: &str| {
210                            v.get(name)
211                                .and_then(serde_json::Value::as_str)
212                                .unwrap_or_default()
213                                .to_owned()
214                        };
215                        self.field_violations.push(FieldViolation {
216                            field: text("field"),
217                            description: text("description"),
218                        });
219                    }
220                }
221                _ => {}
222            }
223        }
224    }
225}
226
227impl fmt::Display for ApiError {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        f.write_str("origincloud: ")?;
230        if !self.reason.is_empty() {
231            f.write_str(&self.reason)?;
232        } else if !self.status.is_empty() {
233            f.write_str(&self.status)?;
234        } else {
235            write!(f, "HTTP {}", self.http_status.unwrap_or_default())?;
236        }
237        if !self.message.is_empty() {
238            write!(f, ": {}", self.message)?;
239        }
240        if !self.request_id.is_empty() {
241            write!(f, " (request {})", self.request_id)?;
242        }
243        Ok(())
244    }
245}
246
247impl std::error::Error for ApiError {}
248
249/// A JSON duration, such as `1.5s`.
250fn parse_duration(text: &str) -> Option<Duration> {
251    let seconds: f64 = text.strip_suffix('s')?.parse().ok()?;
252    Duration::try_from_secs_f64(seconds).ok()
253}
254
255/// The name of a canonical code (`google.rpc.Code`).
256fn code_name(code: i32) -> &'static str {
257    match code {
258        0 => "OK",
259        1 => "CANCELLED",
260        2 => "UNKNOWN",
261        3 => "INVALID_ARGUMENT",
262        4 => "DEADLINE_EXCEEDED",
263        5 => "NOT_FOUND",
264        6 => "ALREADY_EXISTS",
265        7 => "PERMISSION_DENIED",
266        8 => "RESOURCE_EXHAUSTED",
267        9 => "FAILED_PRECONDITION",
268        10 => "ABORTED",
269        11 => "OUT_OF_RANGE",
270        12 => "UNIMPLEMENTED",
271        13 => "INTERNAL",
272        14 => "UNAVAILABLE",
273        15 => "DATA_LOSS",
274        16 => "UNAUTHENTICATED",
275        _ => "UNKNOWN",
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    const BAD_REQUEST: &str = r#"{"error":{"code":400,"status":"INVALID_ARGUMENT","message":"invalid input on field volume.sizeGib: must be at least 1","details":[
284        {"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"BAD_REQUEST","domain":"origincloudapis.eu"},
285        {"@type":"type.googleapis.com/google.rpc.RequestInfo","requestId":"4bf92f3577b34da6"},
286        {"@type":"type.googleapis.com/google.rpc.BadRequest","fieldViolations":[{"field":"volume.sizeGib","description":"must be at least 1"}]}]}}"#;
287
288    #[test]
289    fn a_status_is_read_with_its_details() {
290        let e = ApiError::from_response(400, BAD_REQUEST.as_bytes());
291        assert_eq!(e.http_status, Some(400));
292        assert_eq!(e.status, "INVALID_ARGUMENT");
293        assert_eq!(e.reason, "BAD_REQUEST");
294        assert_eq!(e.request_id, "4bf92f3577b34da6");
295        assert_eq!(
296            e.message,
297            "invalid input on field volume.sizeGib: must be at least 1"
298        );
299        assert_eq!(
300            e.field_violations,
301            vec![FieldViolation {
302                field: "volume.sizeGib".to_owned(),
303                description: "must be at least 1".to_owned()
304            }]
305        );
306        assert!(e.metadata.is_empty());
307        assert!(!e.is_retryable());
308        assert_eq!(
309            e.to_string(),
310            "origincloud: BAD_REQUEST: invalid input on field volume.sizeGib: must be at least 1 (request 4bf92f3577b34da6)"
311        );
312    }
313
314    #[test]
315    fn error_info_metadata_is_read() {
316        let body = r#"{"error":{"code":409,"status":"FAILED_PRECONDITION","message":"already created","details":[
317            {"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"CONFLICT","domain":"origincloudapis.eu","metadata":{"serviceAccount":"organizations/o/serviceAccounts/s","notText":1}}]}}"#;
318        let e = ApiError::from_response(409, body.as_bytes());
319        assert_eq!(e.reason, "CONFLICT");
320        assert_eq!(
321            e.metadata,
322            HashMap::from([(
323                "serviceAccount".to_owned(),
324                "organizations/o/serviceAccounts/s".to_owned()
325            )])
326        );
327    }
328
329    #[test]
330    fn retry_info_makes_it_retryable_with_its_delay() {
331        let body = r#"{"error":{"code":503,"status":"UNAVAILABLE","message":"behind","details":[
332            {"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"1.500s"}]}}"#;
333        let e = ApiError::from_response(503, body.as_bytes());
334        assert!(e.is_retryable());
335        assert_eq!(e.retry_delay, Some(Duration::from_millis(1500)));
336    }
337
338    #[test]
339    fn a_proxy_error_is_known_by_its_status() {
340        let e = ApiError::from_response(502, b"<html>bad gateway</html>");
341        assert!(e.is_retryable());
342        assert_eq!(e.status, "UNAVAILABLE");
343        assert_eq!(e.message, "Bad Gateway");
344        let e = ApiError::from_response(500, b"");
345        assert!(!e.is_retryable());
346        assert_eq!(
347            e.to_string(),
348            "origincloud: HTTP 500: Internal Server Error"
349        );
350    }
351}