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(
23 "origincloud: no credentials: set ORIGINCLOUD_CLIENT_ID and ORIGINCLOUD_CLIENT_SECRET, set ORIGINCLOUD_ACCESS_TOKEN, log in with `origin id login`, or run on an instance that runs as a service account"
24 )]
25 NoCredentials,
26 #[error("origincloud: {0}")]
28 Credentials(String),
29 #[error("origincloud: {0}")]
31 Config(String),
32 #[error("origincloud: {0}")]
35 InvalidRequest(String),
36 #[error("origincloud: {0}")]
38 Decode(String),
39 #[error("origincloud: operation {0} isn't done")]
42 NotDone(String),
43}
44
45impl Error {
46 pub fn api(&self) -> Option<&ApiError> {
48 match self {
49 Self::Api(e) => Some(e),
50 _ => None,
51 }
52 }
53
54 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 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#[derive(Clone, Debug, Default, PartialEq)]
77#[non_exhaustive]
78pub struct ApiError {
79 pub http_status: Option<u16>,
81 pub status: String,
84 pub reason: String,
87 pub metadata: HashMap<String, String>,
91 pub message: String,
93 pub request_id: String,
95 pub field_violations: Vec<FieldViolation>,
97 pub retry_delay: Option<Duration>,
100 retryable: bool,
101}
102
103#[derive(Clone, Debug, Default, PartialEq, Eq)]
105#[non_exhaustive]
106pub struct FieldViolation {
107 pub field: String,
109 pub description: String,
111}
112
113impl ApiError {
114 pub fn is_retryable(&self) -> bool {
117 self.retryable
118 }
119
120 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 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 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
250fn 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
256fn 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}