Skip to main content

origincloud_core/
error.rs

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