1use std::fmt;
2use std::time::Duration;
3
4use serde::Deserialize;
5
6use crate::rpc::{Any, Status};
7
8#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum Error {
12 #[error(transparent)]
15 Api(Box<ApiError>),
16 #[error("origincloud: {0}")]
18 Transport(#[source] reqwest::Error),
19 #[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 #[error("origincloud: {0}")]
26 Credentials(String),
27 #[error("origincloud: {0}")]
29 Config(String),
30 #[error("origincloud: {0}")]
33 InvalidRequest(String),
34 #[error("origincloud: {0}")]
36 Decode(String),
37 #[error("origincloud: operation {0} isn't done")]
40 NotDone(String),
41}
42
43impl Error {
44 pub fn api(&self) -> Option<&ApiError> {
46 match self {
47 Self::Api(e) => Some(e),
48 _ => None,
49 }
50 }
51
52 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 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#[derive(Clone, Debug, Default, PartialEq)]
75#[non_exhaustive]
76pub struct ApiError {
77 pub http_status: Option<u16>,
79 pub status: String,
82 pub reason: String,
85 pub message: String,
87 pub request_id: String,
89 pub field_violations: Vec<FieldViolation>,
91 pub retry_delay: Option<Duration>,
94 retryable: bool,
95}
96
97#[derive(Clone, Debug, Default, PartialEq, Eq)]
99#[non_exhaustive]
100pub struct FieldViolation {
101 pub field: String,
103 pub description: String,
105}
106
107impl ApiError {
108 pub fn is_retryable(&self) -> bool {
111 self.retryable
112 }
113
114 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 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 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
233fn 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
239fn 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}