1use std::collections::HashMap;
2use std::fmt;
3use std::time::Duration;
4
5use serde::Deserialize;
6
7use crate::rpc::{Any, Status};
8
9#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum Error {
13 #[error(transparent)]
16 Api(Box<ApiError>),
17 #[error("origincloud: {0}")]
19 Transport(#[source] reqwest::Error),
20 #[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 #[error("origincloud: {0}")]
27 Credentials(String),
28 #[error("origincloud: {0}")]
30 Config(String),
31 #[error("origincloud: {0}")]
34 InvalidRequest(String),
35 #[error("origincloud: {0}")]
37 Decode(String),
38 #[error("origincloud: operation {0} isn't done")]
41 NotDone(String),
42}
43
44impl Error {
45 pub fn api(&self) -> Option<&ApiError> {
47 match self {
48 Self::Api(e) => Some(e),
49 _ => None,
50 }
51 }
52
53 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 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#[derive(Clone, Debug, Default, PartialEq)]
76#[non_exhaustive]
77pub struct ApiError {
78 pub http_status: Option<u16>,
80 pub status: String,
83 pub reason: String,
86 pub metadata: HashMap<String, String>,
90 pub message: String,
92 pub request_id: String,
94 pub field_violations: Vec<FieldViolation>,
96 pub retry_delay: Option<Duration>,
99 retryable: bool,
100}
101
102#[derive(Clone, Debug, Default, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct FieldViolation {
106 pub field: String,
108 pub description: String,
110}
111
112impl ApiError {
113 pub fn is_retryable(&self) -> bool {
116 self.retryable
117 }
118
119 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 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 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
249fn 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
255fn 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}