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