1use std::fmt;
4
5use serde::de::DeserializeOwned;
6
7use crate::http::{HeaderMap, Method, StatusCode};
8
9pub const EXIT_OK: i32 = 0;
11pub const EXIT_USAGE: i32 = 1;
13pub const EXIT_NOT_FOUND: i32 = 2;
15pub const EXIT_AUTH: i32 = 3;
17pub const EXIT_FORBIDDEN: i32 = 4;
19pub const EXIT_RATE_LIMIT: i32 = 5;
21pub const EXIT_NETWORK: i32 = 6;
23pub const EXIT_API: i32 = 7;
25pub const EXIT_AMBIGUOUS: i32 = 8;
27pub const EXIT_VALIDATION: i32 = 9;
29
30pub const MAX_ERROR_BODY_BYTES: usize = 10 * 1024;
34
35pub const MAX_ERROR_MESSAGE_BYTES: usize = 500;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum ErrorCode {
43 Usage,
45 NotFound,
47 AuthRequired,
49 Forbidden,
51 RateLimit,
53 Network,
55 ApiError,
58 Validation,
60 Ambiguous,
62}
63
64impl ErrorCode {
65 pub fn as_str(&self) -> &'static str {
67 match self {
68 ErrorCode::Usage => "usage",
69 ErrorCode::NotFound => "not_found",
70 ErrorCode::AuthRequired => "auth_required",
71 ErrorCode::Forbidden => "forbidden",
72 ErrorCode::RateLimit => "rate_limit",
73 ErrorCode::Network => "network",
74 ErrorCode::ApiError => "api_error",
75 ErrorCode::Validation => "validation",
76 ErrorCode::Ambiguous => "ambiguous",
77 }
78 }
79
80 pub fn exit_code(&self) -> i32 {
82 match self {
83 ErrorCode::Usage => EXIT_USAGE,
84 ErrorCode::NotFound => EXIT_NOT_FOUND,
85 ErrorCode::AuthRequired => EXIT_AUTH,
86 ErrorCode::Forbidden => EXIT_FORBIDDEN,
87 ErrorCode::RateLimit => EXIT_RATE_LIMIT,
88 ErrorCode::Network => EXIT_NETWORK,
89 ErrorCode::ApiError => EXIT_API,
90 ErrorCode::Validation => EXIT_VALIDATION,
91 ErrorCode::Ambiguous => EXIT_AMBIGUOUS,
92 }
93 }
94}
95
96impl fmt::Display for ErrorCode {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.write_str(self.as_str())
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
106#[non_exhaustive]
107pub enum Refusal {
108 CircuitOpen,
110 BulkheadFull,
112 RateLimited,
114}
115
116#[derive(Debug)]
118pub struct Error {
119 code: ErrorCode,
120 message: String,
121 hint: Option<String>,
122 http_status: Option<u16>,
123 retryable: bool,
124 request_id: Option<String>,
125 refusal: Option<Refusal>,
126 cancelled: bool,
127 source: Option<Box<dyn std::error::Error + Send + Sync>>,
128 response_too_large: bool,
129 body: Option<Box<[u8]>>,
134}
135
136impl Error {
137 pub fn new(code: ErrorCode, message: impl Into<String>) -> Error {
139 Error {
140 code,
141 message: message.into(),
142 hint: None,
143 http_status: None,
144 retryable: false,
145 request_id: None,
146 refusal: None,
147 cancelled: false,
148 source: None,
149 response_too_large: false,
150 body: None,
151 }
152 }
153
154 pub fn usage(message: impl Into<String>) -> Error {
156 Error::new(ErrorCode::Usage, message)
157 }
158
159 pub fn usage_with_hint(message: impl Into<String>, hint: impl Into<String>) -> Error {
161 Error::usage(message).with_hint(hint)
162 }
163
164 pub fn not_found(resource: &str, identifier: impl fmt::Display) -> Error {
166 Error::new(
167 ErrorCode::NotFound,
168 format!("{resource} not found: {identifier}"),
169 )
170 .with_status(404)
171 }
172
173 pub fn auth(message: impl Into<String>) -> Error {
175 Error::new(ErrorCode::AuthRequired, message).with_status(401)
176 }
177
178 pub fn forbidden(message: impl Into<String>) -> Error {
180 Error::new(ErrorCode::Forbidden, message).with_status(403)
181 }
182
183 pub fn forbidden_scope() -> Error {
185 Error::forbidden("Access denied: insufficient scope")
186 .with_hint("Re-authenticate with full scope")
187 }
188
189 pub fn rate_limit(retry_after: Option<u64>) -> Error {
193 Error::new(ErrorCode::RateLimit, "Rate limited")
194 .with_hint(retry_hint(retry_after))
195 .with_status(429)
196 .retryable()
197 }
198
199 pub fn rate_limited() -> Error {
201 Error::new(ErrorCode::RateLimit, "rate limit exceeded").refusing_as(Refusal::RateLimited)
202 }
203
204 pub fn circuit_open() -> Error {
206 Error::new(ErrorCode::ApiError, "circuit breaker is open").refusing_as(Refusal::CircuitOpen)
207 }
208
209 pub fn bulkhead_full() -> Error {
211 Error::new(ErrorCode::ApiError, "bulkhead is full").refusing_as(Refusal::BulkheadFull)
212 }
213
214 pub fn cancelled() -> Error {
223 Error {
224 cancelled: true,
225 ..Error::new(ErrorCode::Network, "operation cancelled")
226 }
227 }
228
229 pub fn network(source: impl std::error::Error + Send + Sync + 'static) -> Error {
231 Error::new(ErrorCode::Network, "Network error")
232 .with_hint(source.to_string())
233 .retryable()
234 .with_source(source)
235 }
236
237 pub fn api(status: u16, message: impl Into<String>) -> Error {
239 Error::new(ErrorCode::ApiError, message).with_status(status)
240 }
241
242 pub fn response_too_large(limit: usize, method: &Method, path: &str) -> Error {
246 Error {
247 response_too_large: true,
248 ..Error::new(
249 ErrorCode::ApiError,
250 format!("{method} {path}: response body exceeds {limit} bytes"),
251 )
252 }
253 }
254
255 pub(crate) fn refusing(mut self, refusal: Error) -> Error {
258 self.response_too_large = refusal.response_too_large;
259 if self.hint.is_none() {
260 self.hint = Some(refusal.to_string());
261 }
262 self.with_source(refusal)
263 }
264
265 pub fn validation(messages: &[String]) -> Error {
268 let mut message = messages.join("; ");
269 if message.is_empty() {
270 message = "validation error".to_string();
271 }
272 Error::new(ErrorCode::Validation, message).with_status(422)
273 }
274
275 pub fn ambiguous(resource: &str, matches: &[String]) -> Error {
278 let hint = match matches.len() {
279 1..=5 => format!("Did you mean: {}", matches.join(", ")),
280 _ => "Be more specific".to_string(),
281 };
282 Error::new(ErrorCode::Ambiguous, format!("Ambiguous {resource}")).with_hint(hint)
283 }
284
285 pub fn from_std(source: impl std::error::Error + Send + Sync + 'static) -> Error {
288 Error::new(ErrorCode::ApiError, source.to_string()).with_source(source)
289 }
290
291 pub fn from_response(
295 status: StatusCode,
296 method: &Method,
297 headers: &HeaderMap,
298 body: &[u8],
299 ) -> Error {
300 let error = match status.as_u16() {
301 401 => Error::auth("Authentication failed"),
302 403 if method != Method::GET => Error::forbidden_scope(),
303 403 => Error::forbidden("Access denied"),
304 404 => Error::new(ErrorCode::NotFound, "Resource not found").with_status(404),
305 422 => Error::new(ErrorCode::Validation, "Validation failed").with_status(422),
306 429 => Error::new(ErrorCode::RateLimit, "Rate limited - try again later")
307 .with_hint(retry_hint(retry_after_seconds(headers)))
308 .with_status(429)
309 .retryable(),
310 code => {
311 let error = Error::api(code, format!("API error: {status}"));
312 if status.is_server_error() {
313 match retry_after_seconds(headers) {
314 Some(seconds) => error.with_hint(retry_hint(Some(seconds))).retryable(),
315 None => error.retryable(),
316 }
317 } else {
318 error
319 }
320 }
321 };
322 let error = match headers
323 .get("x-request-id")
324 .and_then(|value| value.to_str().ok())
325 {
326 Some(request_id) => error.with_request_id(request_id),
327 None => error,
328 };
329 let mut error = match (error.hint.is_none(), server_message(body)) {
330 (true, Some(message)) => error.with_hint(message),
331 _ => error,
332 };
333 if !body.is_empty() {
334 let kept = body.len().min(MAX_ERROR_BODY_BYTES);
335 error.body = Some(Box::from(&body[..kept]));
336 }
337 error
338 }
339
340 pub fn with_hint(mut self, hint: impl Into<String>) -> Error {
342 self.hint = Some(hint.into());
343 self
344 }
345
346 pub fn with_status(mut self, status: u16) -> Error {
348 self.http_status = Some(status);
349 self
350 }
351
352 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Error {
354 self.request_id = Some(request_id.into());
355 self
356 }
357
358 pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Error {
360 self.source = Some(Box::new(source));
361 self
362 }
363
364 pub fn retryable(mut self) -> Error {
366 self.retryable = true;
367 self
368 }
369
370 fn refusing_as(mut self, refusal: Refusal) -> Error {
371 self.refusal = Some(refusal);
372 self
373 }
374
375 pub fn code(&self) -> ErrorCode {
377 self.code
378 }
379
380 pub fn is_code(&self, code: ErrorCode) -> bool {
382 self.code == code
383 }
384
385 pub fn exit_code(&self) -> i32 {
387 self.code.exit_code()
388 }
389
390 pub fn message(&self) -> &str {
392 &self.message
393 }
394
395 pub fn hint(&self) -> Option<&str> {
397 self.hint.as_deref()
398 }
399
400 pub fn http_status(&self) -> Option<u16> {
402 self.http_status
403 }
404
405 pub fn is_retryable(&self) -> bool {
407 self.retryable
408 }
409
410 pub fn request_id(&self) -> Option<&str> {
412 self.request_id.as_deref()
413 }
414
415 pub fn refusal(&self) -> Option<Refusal> {
417 self.refusal
418 }
419
420 pub fn is_cancelled(&self) -> bool {
422 self.cancelled
423 }
424
425 pub fn is_response_too_large(&self) -> bool {
428 self.response_too_large
429 }
430
431 pub fn body(&self) -> Option<&[u8]> {
436 self.body.as_deref()
437 }
438
439 pub fn body_json<T: DeserializeOwned>(&self) -> Option<T> {
442 serde_json::from_slice(self.body()?).ok()
443 }
444}
445
446impl fmt::Display for Error {
447 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
448 match &self.hint {
449 Some(hint) => write!(f, "{}: {hint}", self.message),
450 None => f.write_str(&self.message),
451 }
452 }
453}
454
455impl std::error::Error for Error {
456 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
457 self.source
458 .as_deref()
459 .map(|source| source as &(dyn std::error::Error + 'static))
460 }
461}
462
463impl From<serde_json::Error> for Error {
464 fn from(error: serde_json::Error) -> Error {
465 Error::new(ErrorCode::ApiError, "unexpected JSON")
466 .with_hint(error.to_string())
467 .with_source(error)
468 }
469}
470
471impl From<url::ParseError> for Error {
472 fn from(error: url::ParseError) -> Error {
473 Error::usage(format!("invalid URL: {error}")).with_source(error)
474 }
475}
476
477fn retry_hint(retry_after: Option<u64>) -> String {
478 match retry_after {
479 Some(seconds) if seconds > 0 => format!("Try again in {seconds} seconds"),
480 _ => "Try again later".to_string(),
481 }
482}
483
484pub(crate) fn retry_after_seconds(headers: &HeaderMap) -> Option<u64> {
488 let asked = headers.get("retry-after")?.to_str().ok()?.trim();
489 if let Ok(seconds) = asked.parse::<i64>() {
490 u64::try_from(seconds).ok()
491 } else {
492 let until = chrono::DateTime::parse_from_rfc2822(asked).ok()?;
493 seconds_until(until.with_timezone(&chrono::Utc), chrono::Utc::now())
494 }
495}
496
497fn seconds_until(
500 until: chrono::DateTime<chrono::Utc>,
501 now: chrono::DateTime<chrono::Utc>,
502) -> Option<u64> {
503 let left = until.signed_duration_since(now);
504 let whole = left.num_seconds();
505 let started = left > chrono::Duration::seconds(whole);
506 u64::try_from(if started { whole + 1 } else { whole }).ok()
507}
508
509fn server_message(body: &[u8]) -> Option<String> {
510 let value: serde_json::Value = serde_json::from_slice(body).ok()?;
511 let message = value
512 .get("message")
513 .or_else(|| value.get("error"))?
514 .as_str()?;
515 Some(truncate(message, MAX_ERROR_MESSAGE_BYTES))
516}
517
518pub(crate) fn truncate(message: &str, limit: usize) -> String {
521 if message.chars().count() <= limit {
522 message.to_string()
523 } else {
524 let kept: String = message.chars().take(limit.saturating_sub(3)).collect();
525 format!("{kept}...")
526 }
527}
528
529#[cfg(test)]
530#[allow(clippy::unwrap_used)]
531mod tests {
532 use super::*;
533 use chrono::{DateTime, Duration, Utc};
534
535 fn at(millis: i64) -> DateTime<Utc> {
536 DateTime::from_timestamp_millis(1_700_000_000_000 + millis).unwrap()
537 }
538
539 #[test]
540 fn a_started_second_counts_as_a_whole_one() {
541 assert_eq!(seconds_until(at(2_000), at(0)), Some(2));
542 assert_eq!(seconds_until(at(2_000), at(50)), Some(2));
543 assert_eq!(seconds_until(at(2_000), at(1_050)), Some(1));
544 assert_eq!(seconds_until(at(2_000), at(1_999)), Some(1));
545 assert_eq!(
546 seconds_until(at(2_000), at(0) - Duration::nanoseconds(1)),
547 Some(3)
548 );
549 }
550
551 #[test]
552 fn a_date_already_past_asks_for_no_wait() {
553 assert_eq!(seconds_until(at(0), at(0)), Some(0));
554 assert_eq!(seconds_until(at(0), at(500)), Some(0));
555 assert_eq!(seconds_until(at(0), at(1_500)), None);
556 }
557
558 #[test]
559 fn retry_after_reads_seconds_and_dates() {
560 let mut headers = HeaderMap::new();
561 headers.insert("retry-after", "7".parse().unwrap());
562 assert_eq!(retry_after_seconds(&headers), Some(7));
563 headers.insert(
564 "retry-after",
565 "Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap(),
566 );
567 assert_eq!(retry_after_seconds(&headers), None);
568 headers.insert("retry-after", "soon".parse().unwrap());
569 assert_eq!(retry_after_seconds(&headers), None);
570 }
571
572 #[test]
573 fn every_code_has_an_exit_status_and_a_name() {
574 let codes = [
575 (ErrorCode::Usage, "usage", 1),
576 (ErrorCode::NotFound, "not_found", 2),
577 (ErrorCode::AuthRequired, "auth_required", 3),
578 (ErrorCode::Forbidden, "forbidden", 4),
579 (ErrorCode::RateLimit, "rate_limit", 5),
580 (ErrorCode::Network, "network", 6),
581 (ErrorCode::ApiError, "api_error", 7),
582 (ErrorCode::Validation, "validation", 9),
583 (ErrorCode::Ambiguous, "ambiguous", 8),
584 ];
585 for (code, name, exit) in codes {
586 assert_eq!(code.as_str(), name);
587 assert_eq!(code.exit_code(), exit);
588 }
589 }
590
591 #[test]
592 fn refusals_carry_no_status_and_say_which_layer() {
593 assert_eq!(Error::circuit_open().refusal(), Some(Refusal::CircuitOpen));
594 assert_eq!(
595 Error::bulkhead_full().refusal(),
596 Some(Refusal::BulkheadFull)
597 );
598 assert_eq!(Error::rate_limited().refusal(), Some(Refusal::RateLimited));
599 assert_eq!(Error::rate_limited().code(), ErrorCode::RateLimit);
600 assert_eq!(Error::circuit_open().http_status(), None);
601 assert_eq!(Error::api(500, "boom").refusal(), None);
602 }
603}