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)]
40pub enum ErrorCode {
41 Usage,
42 NotFound,
43 Auth,
44 Forbidden,
45 RateLimit,
46 Network,
47 Api,
48 Validation,
49 Ambiguous,
50 Conflict,
51 CircuitOpen,
54 BulkheadFull,
56}
57
58impl ErrorCode {
59 pub fn as_str(&self) -> &'static str {
60 match self {
61 ErrorCode::Usage => "usage",
62 ErrorCode::NotFound => "not_found",
63 ErrorCode::Auth => "auth_required",
64 ErrorCode::Forbidden => "forbidden",
65 ErrorCode::RateLimit => "rate_limit",
66 ErrorCode::Network => "network",
67 ErrorCode::Api => "api_error",
68 ErrorCode::Validation => "validation",
69 ErrorCode::Ambiguous => "ambiguous",
70 ErrorCode::Conflict => "conflict",
71 ErrorCode::CircuitOpen => "circuit_open",
72 ErrorCode::BulkheadFull => "bulkhead_full",
73 }
74 }
75
76 pub fn exit_code(&self) -> i32 {
81 match self {
82 ErrorCode::Usage => EXIT_USAGE,
83 ErrorCode::NotFound => EXIT_NOT_FOUND,
84 ErrorCode::Auth => EXIT_AUTH,
85 ErrorCode::Forbidden => EXIT_FORBIDDEN,
86 ErrorCode::RateLimit => EXIT_RATE_LIMIT,
87 ErrorCode::Network => EXIT_NETWORK,
88 ErrorCode::Api => EXIT_API,
89 ErrorCode::Validation => EXIT_VALIDATION,
90 ErrorCode::Ambiguous => EXIT_AMBIGUOUS,
91 ErrorCode::Conflict => EXIT_VALIDATION,
92 ErrorCode::CircuitOpen | ErrorCode::BulkheadFull => EXIT_API,
93 }
94 }
95}
96
97impl fmt::Display for ErrorCode {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.write_str(self.as_str())
100 }
101}
102
103#[derive(Debug)]
105pub struct Error {
106 code: ErrorCode,
107 message: String,
108 hint: Option<String>,
109 http_status: Option<u16>,
110 retryable: bool,
111 request_id: Option<String>,
112 source: Option<Box<dyn std::error::Error + Send + Sync>>,
113 response_too_large: bool,
114 body: Option<Box<[u8]>>,
120}
121
122impl Error {
123 pub fn new(code: ErrorCode, message: impl Into<String>) -> Error {
124 Error {
125 code,
126 message: message.into(),
127 hint: None,
128 http_status: None,
129 retryable: false,
130 request_id: None,
131 source: None,
132 response_too_large: false,
133 body: None,
134 }
135 }
136
137 pub fn usage(message: impl Into<String>) -> Error {
138 Error::new(ErrorCode::Usage, message)
139 }
140
141 pub fn usage_with_hint(message: impl Into<String>, hint: impl Into<String>) -> Error {
142 Error::usage(message).with_hint(hint)
143 }
144
145 pub fn not_found(resource: &str, identifier: impl fmt::Display) -> Error {
146 Error::new(
147 ErrorCode::NotFound,
148 format!("{resource} not found: {identifier}"),
149 )
150 .with_status(404)
151 }
152
153 pub fn not_found_with_hint(
154 resource: &str,
155 identifier: impl fmt::Display,
156 hint: impl Into<String>,
157 ) -> Error {
158 Error::not_found(resource, identifier).with_hint(hint)
159 }
160
161 pub fn auth(message: impl Into<String>) -> Error {
162 Error::new(ErrorCode::Auth, message).with_status(401)
163 }
164
165 pub fn forbidden(message: impl Into<String>) -> Error {
166 Error::new(ErrorCode::Forbidden, message).with_status(403)
167 }
168
169 pub fn forbidden_scope() -> Error {
170 Error::forbidden("Access denied: insufficient scope")
171 .with_hint("Re-authenticate with full scope")
172 }
173
174 pub fn rate_limit(retry_after: Option<u64>) -> Error {
178 Error::new(ErrorCode::RateLimit, "Rate limited")
179 .with_hint(retry_hint(retry_after))
180 .with_status(429)
181 .retryable()
182 }
183
184 pub fn rate_limited() -> Error {
188 Error::new(ErrorCode::RateLimit, "rate limit exceeded")
189 }
190
191 pub fn circuit_open() -> Error {
193 Error::new(ErrorCode::CircuitOpen, "circuit breaker is open")
194 }
195
196 pub fn cancelled() -> Error {
206 Error::new(ErrorCode::Network, "operation cancelled")
207 }
208
209 pub fn bulkhead_full() -> Error {
211 Error::new(ErrorCode::BulkheadFull, "bulkhead is full")
212 }
213
214 pub fn network(source: impl std::error::Error + Send + Sync + 'static) -> Error {
215 Error::new(ErrorCode::Network, "Network error")
216 .with_hint(source.to_string())
217 .retryable()
218 .with_source(source)
219 }
220
221 pub fn api(status: u16, message: impl Into<String>) -> Error {
222 Error::new(ErrorCode::Api, message).with_status(status)
223 }
224
225 pub fn conflict(message: impl Into<String>) -> Error {
226 Error::new(ErrorCode::Conflict, message).with_status(409)
227 }
228
229 pub fn response_too_large(limit: usize, method: &Method, path: &str) -> Error {
233 Error {
234 response_too_large: true,
235 ..Error::api(
236 0,
237 format!("{method} {path}: response body exceeds {limit} bytes"),
238 )
239 }
240 }
241
242 pub(crate) fn refusing(mut self, refusal: Error) -> Error {
245 self.response_too_large = refusal.response_too_large;
246 if self.hint.is_none() {
247 self.hint = Some(refusal.to_string());
248 }
249 self.with_source(refusal)
250 }
251
252 pub fn validation(messages: &[String]) -> Error {
255 let mut message = messages.join("; ");
256 if message.is_empty() {
257 message = "validation error".to_string();
258 }
259 Error::new(ErrorCode::Validation, message).with_status(422)
260 }
261
262 pub fn ambiguous(resource: &str, matches: &[String]) -> Error {
269 let hint = match matches.len() {
270 1..=5 => format!("Did you mean: {}", matches.join(", ")),
271 _ => "Be more specific".to_string(),
272 };
273 Error::new(ErrorCode::Ambiguous, format!("Ambiguous {resource}")).with_hint(hint)
274 }
275
276 pub fn from_std(source: impl std::error::Error + Send + Sync + 'static) -> Error {
279 Error::new(ErrorCode::Api, source.to_string()).with_source(source)
280 }
281
282 pub fn from_response(
286 status: StatusCode,
287 method: &Method,
288 headers: &HeaderMap,
289 body: &[u8],
290 ) -> Error {
291 let error = match status.as_u16() {
292 401 => Error::auth("authentication required"),
293 403 if method != Method::GET => Error::forbidden_scope(),
294 403 => Error::forbidden("access denied"),
295 404 => Error::new(ErrorCode::NotFound, "resource not found").with_status(404),
296 422 => Error::new(ErrorCode::Validation, "validation error").with_status(422),
297 429 => Error::new(ErrorCode::RateLimit, "rate limited - try again later")
298 .with_hint(retry_hint(retry_after_seconds(headers)))
299 .with_status(429)
300 .retryable(),
301 code => {
302 let error = Error::api(code, format!("API error: {status}"));
303 if status.is_server_error() {
304 error.retryable()
305 } else {
306 error
307 }
308 }
309 };
310 let error = match headers
311 .get("x-request-id")
312 .and_then(|value| value.to_str().ok())
313 {
314 Some(request_id) => error.with_request_id(request_id),
315 None => error,
316 };
317 let mut error = match (error.hint.is_none(), server_message(body)) {
318 (true, Some(message)) => error.with_hint(message),
319 _ => error,
320 };
321 if !body.is_empty() {
322 let kept = body.len().min(MAX_ERROR_BODY_BYTES);
323 error.body = Some(Box::from(&body[..kept]));
324 }
325 error
326 }
327
328 pub fn with_hint(mut self, hint: impl Into<String>) -> Error {
329 self.hint = Some(hint.into());
330 self
331 }
332
333 pub fn with_status(mut self, status: u16) -> Error {
334 self.http_status = Some(status);
335 self
336 }
337
338 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Error {
339 self.request_id = Some(request_id.into());
340 self
341 }
342
343 pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Error {
344 self.source = Some(Box::new(source));
345 self
346 }
347
348 pub fn retryable(mut self) -> Error {
349 self.retryable = true;
350 self
351 }
352
353 pub fn code(&self) -> ErrorCode {
354 self.code
355 }
356
357 pub fn is_code(&self, code: ErrorCode) -> bool {
358 self.code == code
359 }
360
361 pub fn exit_code(&self) -> i32 {
363 self.code.exit_code()
364 }
365
366 pub fn message(&self) -> &str {
367 &self.message
368 }
369
370 pub fn hint(&self) -> Option<&str> {
371 self.hint.as_deref()
372 }
373
374 pub fn http_status(&self) -> Option<u16> {
375 self.http_status
376 }
377
378 pub fn is_retryable(&self) -> bool {
379 self.retryable
380 }
381
382 pub fn request_id(&self) -> Option<&str> {
383 self.request_id.as_deref()
384 }
385
386 pub fn is_response_too_large(&self) -> bool {
389 self.response_too_large
390 }
391
392 pub fn body(&self) -> Option<&[u8]> {
398 self.body.as_deref()
399 }
400
401 pub fn body_json<T: DeserializeOwned>(&self) -> Option<T> {
419 serde_json::from_slice(self.body()?).ok()
420 }
421}
422
423impl fmt::Display for Error {
424 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425 match &self.hint {
426 Some(hint) => write!(f, "{}: {hint}", self.message),
427 None => f.write_str(&self.message),
428 }
429 }
430}
431
432impl std::error::Error for Error {
433 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
434 self.source
435 .as_deref()
436 .map(|source| source as &(dyn std::error::Error + 'static))
437 }
438}
439
440impl From<serde_json::Error> for Error {
441 fn from(error: serde_json::Error) -> Error {
442 Error::api(0, "unexpected JSON")
443 .with_hint(error.to_string())
444 .with_source(error)
445 }
446}
447
448impl From<url::ParseError> for Error {
449 fn from(error: url::ParseError) -> Error {
450 Error::usage(format!("invalid URL: {error}")).with_source(error)
451 }
452}
453
454fn retry_hint(retry_after: Option<u64>) -> String {
455 match retry_after {
456 Some(seconds) if seconds > 0 => format!("Try again in {seconds} seconds"),
457 _ => "Try again later".to_string(),
458 }
459}
460
461pub(crate) fn retry_after_seconds(headers: &HeaderMap) -> Option<u64> {
465 let asked = headers.get("retry-after")?.to_str().ok()?.trim();
466 match asked.parse::<i64>() {
467 Ok(seconds) => u64::try_from(seconds).ok(),
468 Err(_) => {
469 let until = chrono::DateTime::parse_from_rfc2822(asked).ok()?;
470 seconds_until(until.with_timezone(&chrono::Utc), chrono::Utc::now())
471 }
472 }
473}
474
475fn seconds_until(
478 until: chrono::DateTime<chrono::Utc>,
479 now: chrono::DateTime<chrono::Utc>,
480) -> Option<u64> {
481 let left = until.signed_duration_since(now);
482 let whole = left.num_seconds();
483 let started = left > chrono::Duration::seconds(whole);
484 u64::try_from(if started { whole + 1 } else { whole }).ok()
485}
486
487fn server_message(body: &[u8]) -> Option<String> {
488 let value: serde_json::Value = serde_json::from_slice(body).ok()?;
489 let message = value
490 .get("message")
491 .or_else(|| value.get("error"))?
492 .as_str()?;
493 Some(truncate(message, MAX_ERROR_MESSAGE_BYTES))
494}
495
496pub(crate) fn truncate(message: &str, limit: usize) -> String {
499 if message.chars().count() <= limit {
500 message.to_string()
501 } else {
502 let kept: String = message.chars().take(limit - 3).collect();
503 format!("{kept}...")
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::seconds_until;
510 use chrono::{DateTime, Duration, Utc};
511
512 fn at(millis: i64) -> DateTime<Utc> {
513 DateTime::from_timestamp_millis(1_700_000_000_000 + millis).unwrap()
514 }
515
516 #[test]
517 fn a_started_second_counts_as_a_whole_one() {
518 assert_eq!(seconds_until(at(2_000), at(0)), Some(2));
519 assert_eq!(seconds_until(at(2_000), at(50)), Some(2));
520 assert_eq!(seconds_until(at(2_000), at(1_050)), Some(1));
521 assert_eq!(seconds_until(at(2_000), at(1_999)), Some(1));
522 assert_eq!(
523 seconds_until(at(2_000), at(0) - Duration::nanoseconds(1)),
524 Some(3)
525 );
526 assert_eq!(
527 seconds_until(at(2_000), at(2_000) - Duration::nanoseconds(1)),
528 Some(1)
529 );
530 }
531
532 #[test]
533 fn a_date_already_past_asks_for_no_wait() {
534 assert_eq!(seconds_until(at(0), at(0)), Some(0));
535 assert_eq!(seconds_until(at(0), at(500)), Some(0));
536 assert_eq!(seconds_until(at(0), at(1_500)), None);
537 assert_eq!(seconds_until(at(0) - Duration::hours(1), at(0)), None);
538 }
539}