1use std::fmt;
2
3use serde::de::DeserializeOwned;
4
5use crate::http::{HeaderMap, Method, StatusCode};
6
7pub const EXIT_OK: i32 = 0;
9pub const EXIT_USAGE: i32 = 1;
11pub const EXIT_NOT_FOUND: i32 = 2;
13pub const EXIT_AUTH: i32 = 3;
15pub const EXIT_FORBIDDEN: i32 = 4;
17pub const EXIT_RATE_LIMIT: i32 = 5;
19pub const EXIT_NETWORK: i32 = 6;
21pub const EXIT_API: i32 = 7;
23pub const EXIT_AMBIGUOUS: i32 = 8;
25pub const EXIT_VALIDATION: i32 = 9;
28
29pub const MAX_ERROR_BODY_BYTES: usize = 1 << 20;
33
34pub const MAX_ERROR_MESSAGE_BYTES: usize = 500;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40#[non_exhaustive]
41pub enum ErrorCode {
42 Usage,
44 NotFound,
46 Auth,
48 Forbidden,
50 RateLimit,
52 Network,
54 Api,
56 Validation,
58 Ambiguous,
60 Conflict,
62 CircuitOpen,
65 BulkheadFull,
67}
68
69impl ErrorCode {
70 pub fn as_str(&self) -> &'static str {
72 match self {
73 ErrorCode::Usage => "usage",
74 ErrorCode::NotFound => "not_found",
75 ErrorCode::Auth => "auth_required",
76 ErrorCode::Forbidden => "forbidden",
77 ErrorCode::RateLimit => "rate_limit",
78 ErrorCode::Network => "network",
79 ErrorCode::Api => "api_error",
80 ErrorCode::Validation => "validation",
81 ErrorCode::Ambiguous => "ambiguous",
82 ErrorCode::Conflict => "conflict",
83 ErrorCode::CircuitOpen => "circuit_open",
84 ErrorCode::BulkheadFull => "bulkhead_full",
85 }
86 }
87
88 pub fn exit_code(&self) -> i32 {
93 match self {
94 ErrorCode::Usage => EXIT_USAGE,
95 ErrorCode::NotFound => EXIT_NOT_FOUND,
96 ErrorCode::Auth => EXIT_AUTH,
97 ErrorCode::Forbidden => EXIT_FORBIDDEN,
98 ErrorCode::RateLimit => EXIT_RATE_LIMIT,
99 ErrorCode::Network => EXIT_NETWORK,
100 ErrorCode::Validation | ErrorCode::Conflict => EXIT_VALIDATION,
101 ErrorCode::Ambiguous => EXIT_AMBIGUOUS,
102 ErrorCode::Api | ErrorCode::CircuitOpen | ErrorCode::BulkheadFull => EXIT_API,
103 }
104 }
105}
106
107impl fmt::Display for ErrorCode {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 f.write_str(self.as_str())
110 }
111}
112
113#[derive(Debug)]
115pub struct Error {
116 code: ErrorCode,
117 message: String,
118 hint: Option<String>,
119 http_status: Option<u16>,
120 retryable: bool,
121 request_id: Option<String>,
122 source: Option<Box<dyn std::error::Error + Send + Sync>>,
123 response_too_large: bool,
124 body: Option<Box<[u8]>>,
130}
131
132impl Error {
133 pub fn new(code: ErrorCode, message: impl Into<String>) -> Error {
135 Error {
136 code,
137 message: message.into(),
138 hint: None,
139 http_status: None,
140 retryable: false,
141 request_id: None,
142 source: None,
143 response_too_large: false,
144 body: None,
145 }
146 }
147
148 pub fn usage(message: impl Into<String>) -> Error {
150 Error::new(ErrorCode::Usage, message)
151 }
152
153 pub fn usage_with_hint(message: impl Into<String>, hint: impl Into<String>) -> Error {
155 Error::usage(message).with_hint(hint)
156 }
157
158 pub fn not_found(resource: &str, identifier: impl fmt::Display) -> Error {
160 Error::new(
161 ErrorCode::NotFound,
162 format!("{resource} not found: {identifier}"),
163 )
164 .with_status(404)
165 }
166
167 pub fn not_found_with_hint(
169 resource: &str,
170 identifier: impl fmt::Display,
171 hint: impl Into<String>,
172 ) -> Error {
173 Error::not_found(resource, identifier).with_hint(hint)
174 }
175
176 pub fn auth(message: impl Into<String>) -> Error {
178 Error::new(ErrorCode::Auth, message).with_status(401)
179 }
180
181 pub fn forbidden(message: impl Into<String>) -> Error {
183 Error::new(ErrorCode::Forbidden, message).with_status(403)
184 }
185
186 pub fn forbidden_scope() -> Error {
188 Error::forbidden("Access denied: insufficient scope")
189 .with_hint("Re-authenticate with full scope")
190 }
191
192 pub fn rate_limit(retry_after: Option<u64>) -> Error {
196 Error::new(ErrorCode::RateLimit, "Rate limited")
197 .with_hint(retry_hint(retry_after))
198 .with_status(429)
199 .retryable()
200 }
201
202 pub fn rate_limited() -> Error {
206 Error::new(ErrorCode::RateLimit, "rate limit exceeded")
207 }
208
209 pub fn circuit_open() -> Error {
211 Error::new(ErrorCode::CircuitOpen, "circuit breaker is open")
212 }
213
214 pub fn cancelled() -> Error {
224 Error::new(ErrorCode::Network, "operation cancelled")
225 }
226
227 pub fn bulkhead_full() -> Error {
229 Error::new(ErrorCode::BulkheadFull, "bulkhead is full")
230 }
231
232 pub fn network(source: impl std::error::Error + Send + Sync + 'static) -> Error {
238 Error::new(ErrorCode::Network, "Network error")
239 .with_hint(source.to_string())
240 .retryable()
241 .with_source(source)
242 }
243
244 pub fn api(status: u16, message: impl Into<String>) -> Error {
246 Error::new(ErrorCode::Api, message).with_status(status)
247 }
248
249 pub fn conflict(message: impl Into<String>) -> Error {
251 Error::new(ErrorCode::Conflict, message).with_status(409)
252 }
253
254 pub fn response_too_large(limit: usize, method: &Method, path: &str) -> Error {
258 Error {
259 response_too_large: true,
260 ..Error::api(
261 0,
262 format!("{method} {path}: response body exceeds {limit} bytes"),
263 )
264 }
265 }
266
267 pub(crate) fn refusing(mut self, refusal: Error) -> Error {
270 self.response_too_large = refusal.response_too_large;
271 if self.hint.is_none() {
272 self.hint = Some(refusal.to_string());
273 }
274 self.with_source(refusal)
275 }
276
277 pub fn validation(messages: &[String]) -> Error {
280 let mut message = messages.join("; ");
281 if message.is_empty() {
282 message = "validation error".to_string();
283 }
284 Error::new(ErrorCode::Validation, message).with_status(422)
285 }
286
287 pub fn ambiguous(resource: &str, matches: &[String]) -> Error {
294 let hint = match matches.len() {
295 1..=5 => format!("Did you mean: {}", matches.join(", ")),
296 _ => "Be more specific".to_string(),
297 };
298 Error::new(ErrorCode::Ambiguous, format!("Ambiguous {resource}")).with_hint(hint)
299 }
300
301 pub fn from_std(source: impl std::error::Error + Send + Sync + 'static) -> Error {
304 Error::new(ErrorCode::Api, source.to_string()).with_source(source)
305 }
306
307 pub fn from_response(
311 status: StatusCode,
312 method: &Method,
313 headers: &HeaderMap,
314 body: &[u8],
315 ) -> Error {
316 let error = match status.as_u16() {
317 401 => Error::auth("authentication required"),
318 403 if method != Method::GET => Error::forbidden_scope(),
319 403 => Error::forbidden("access denied"),
320 404 => Error::new(ErrorCode::NotFound, "resource not found").with_status(404),
321 422 => Error::new(ErrorCode::Validation, "validation error").with_status(422),
322 429 => Error::new(ErrorCode::RateLimit, "rate limited - try again later")
323 .with_hint(retry_hint(retry_after_seconds(headers)))
324 .with_status(429)
325 .retryable(),
326 code => {
327 let error = Error::api(code, format!("API error: {status}"));
328 if status.is_server_error() {
329 error.retryable()
330 } else {
331 error
332 }
333 }
334 };
335 let error = match headers
336 .get("x-request-id")
337 .and_then(|value| value.to_str().ok())
338 {
339 Some(request_id) => error.with_request_id(request_id),
340 None => error,
341 };
342 let mut error = match (error.hint.is_none(), server_message(body)) {
343 (true, Some(message)) => error.with_hint(message),
344 _ => error,
345 };
346 if !body.is_empty() {
347 let kept = body.len().min(MAX_ERROR_BODY_BYTES);
348 error.body = Some(Box::from(&body[..kept]));
349 }
350 error
351 }
352
353 #[must_use]
355 pub fn with_hint(mut self, hint: impl Into<String>) -> Error {
356 self.hint = Some(hint.into());
357 self
358 }
359
360 #[must_use]
362 pub fn with_status(mut self, status: u16) -> Error {
363 self.http_status = Some(status);
364 self
365 }
366
367 #[must_use]
369 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Error {
370 self.request_id = Some(request_id.into());
371 self
372 }
373
374 #[must_use]
376 pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Error {
377 self.source = Some(Box::new(source));
378 self
379 }
380
381 #[must_use]
383 pub fn retryable(mut self) -> Error {
384 self.retryable = true;
385 self
386 }
387
388 pub fn code(&self) -> ErrorCode {
390 self.code
391 }
392
393 pub fn is_code(&self, code: ErrorCode) -> bool {
395 self.code == code
396 }
397
398 pub fn exit_code(&self) -> i32 {
400 self.code.exit_code()
401 }
402
403 pub fn message(&self) -> &str {
405 &self.message
406 }
407
408 pub fn hint(&self) -> Option<&str> {
411 self.hint.as_deref()
412 }
413
414 pub fn http_status(&self) -> Option<u16> {
416 self.http_status
417 }
418
419 pub fn is_retryable(&self) -> bool {
421 self.retryable
422 }
423
424 pub fn request_id(&self) -> Option<&str> {
426 self.request_id.as_deref()
427 }
428
429 pub fn is_response_too_large(&self) -> bool {
432 self.response_too_large
433 }
434
435 pub fn body(&self) -> Option<&[u8]> {
441 self.body.as_deref()
442 }
443
444 pub fn body_json<T: DeserializeOwned>(&self) -> Option<T> {
462 serde_json::from_slice(self.body()?).ok()
463 }
464}
465
466impl fmt::Display for Error {
467 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
468 match &self.hint {
469 Some(hint) => write!(f, "{}: {hint}", self.message),
470 None => f.write_str(&self.message),
471 }
472 }
473}
474
475impl std::error::Error for Error {
476 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
477 self.source
478 .as_deref()
479 .map(|source| source as &(dyn std::error::Error + 'static))
480 }
481}
482
483impl From<serde_json::Error> for Error {
484 fn from(error: serde_json::Error) -> Error {
485 Error::api(0, "unexpected JSON")
486 .with_hint(error.to_string())
487 .with_source(error)
488 }
489}
490
491impl Error {
492 pub(crate) fn decoding(
496 status: u16,
497 request_id: Option<&str>,
498 error: serde_json::Error,
499 ) -> Error {
500 let error = Error::new(ErrorCode::Api, "unexpected JSON in the response")
501 .with_status(status)
502 .with_hint(error.to_string())
503 .with_source(error);
504 match request_id {
505 Some(request_id) => error.with_request_id(request_id),
506 None => error,
507 }
508 }
509
510 pub(crate) fn about(mut self, operation: &str) -> Error {
512 self.message = format!("{operation}: {}", self.message);
513 self
514 }
515
516 pub fn timed_out(limit: std::time::Duration) -> Error {
519 Error::new(
520 ErrorCode::Network,
521 format!("operation timed out after {limit:?}"),
522 )
523 .retryable()
524 }
525
526 pub fn pagination_capped(max_pages: usize) -> Error {
530 Error::usage(format!(
531 "pagination stopped at the page limit of {max_pages} with more pages to read"
532 ))
533 .with_hint("raise max_pages on the client, or read with a limit")
534 }
535}
536
537impl From<url::ParseError> for Error {
538 fn from(error: url::ParseError) -> Error {
539 Error::usage(format!("invalid URL: {error}")).with_source(error)
540 }
541}
542
543fn retry_hint(retry_after: Option<u64>) -> String {
544 match retry_after {
545 Some(seconds) if seconds > 0 => format!("Try again in {seconds} seconds"),
546 _ => "Try again later".to_string(),
547 }
548}
549
550pub(crate) fn retry_after_seconds(headers: &HeaderMap) -> Option<u64> {
554 let asked = headers.get("retry-after")?.to_str().ok()?.trim();
555 if let Ok(seconds) = asked.parse::<i64>() {
556 u64::try_from(seconds).ok()
557 } else {
558 let until = chrono::DateTime::parse_from_rfc2822(asked).ok()?;
559 seconds_until(until.with_timezone(&chrono::Utc), chrono::Utc::now())
560 }
561}
562
563fn seconds_until(
566 until: chrono::DateTime<chrono::Utc>,
567 now: chrono::DateTime<chrono::Utc>,
568) -> Option<u64> {
569 let left = until.signed_duration_since(now);
570 let whole = left.num_seconds();
571 let started = left > chrono::Duration::seconds(whole);
572 u64::try_from(if started { whole + 1 } else { whole }).ok()
573}
574
575fn server_message(body: &[u8]) -> Option<String> {
576 let value: serde_json::Value = serde_json::from_slice(body).ok()?;
577 let message = value
578 .get("message")
579 .or_else(|| value.get("error"))?
580 .as_str()?;
581 Some(truncate(message, MAX_ERROR_MESSAGE_BYTES))
582}
583
584pub(crate) fn truncate(message: &str, limit: usize) -> String {
587 if message.chars().count() <= limit {
588 message.to_string()
589 } else {
590 let kept: String = message.chars().take(limit - 3).collect();
591 format!("{kept}...")
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::seconds_until;
598 use chrono::{DateTime, Duration, Utc};
599
600 fn at(millis: i64) -> DateTime<Utc> {
601 DateTime::from_timestamp_millis(1_700_000_000_000 + millis).unwrap()
602 }
603
604 #[test]
605 fn a_started_second_counts_as_a_whole_one() {
606 assert_eq!(seconds_until(at(2_000), at(0)), Some(2));
607 assert_eq!(seconds_until(at(2_000), at(50)), Some(2));
608 assert_eq!(seconds_until(at(2_000), at(1_050)), Some(1));
609 assert_eq!(seconds_until(at(2_000), at(1_999)), Some(1));
610 assert_eq!(
611 seconds_until(at(2_000), at(0) - Duration::nanoseconds(1)),
612 Some(3)
613 );
614 assert_eq!(
615 seconds_until(at(2_000), at(2_000) - Duration::nanoseconds(1)),
616 Some(1)
617 );
618 }
619
620 #[test]
621 fn a_date_already_past_asks_for_no_wait() {
622 assert_eq!(seconds_until(at(0), at(0)), Some(0));
623 assert_eq!(seconds_until(at(0), at(500)), Some(0));
624 assert_eq!(seconds_until(at(0), at(1_500)), None);
625 assert_eq!(seconds_until(at(0) - Duration::hours(1), at(0)), None);
626 }
627}