use std::fmt;
use serde::de::DeserializeOwned;
use crate::http::{HeaderMap, Method, StatusCode};
pub const EXIT_OK: i32 = 0;
pub const EXIT_USAGE: i32 = 1;
pub const EXIT_NOT_FOUND: i32 = 2;
pub const EXIT_AUTH: i32 = 3;
pub const EXIT_FORBIDDEN: i32 = 4;
pub const EXIT_RATE_LIMIT: i32 = 5;
pub const EXIT_NETWORK: i32 = 6;
pub const EXIT_API: i32 = 7;
pub const EXIT_AMBIGUOUS: i32 = 8;
pub const EXIT_VALIDATION: i32 = 9;
pub const MAX_ERROR_BODY_BYTES: usize = 1 << 20;
pub const MAX_ERROR_MESSAGE_BYTES: usize = 500;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorCode {
Usage,
NotFound,
Auth,
Forbidden,
RateLimit,
Network,
Api,
Validation,
Ambiguous,
Conflict,
CircuitOpen,
BulkheadFull,
}
impl ErrorCode {
pub fn as_str(&self) -> &'static str {
match self {
ErrorCode::Usage => "usage",
ErrorCode::NotFound => "not_found",
ErrorCode::Auth => "auth_required",
ErrorCode::Forbidden => "forbidden",
ErrorCode::RateLimit => "rate_limit",
ErrorCode::Network => "network",
ErrorCode::Api => "api_error",
ErrorCode::Validation => "validation",
ErrorCode::Ambiguous => "ambiguous",
ErrorCode::Conflict => "conflict",
ErrorCode::CircuitOpen => "circuit_open",
ErrorCode::BulkheadFull => "bulkhead_full",
}
}
pub fn exit_code(&self) -> i32 {
match self {
ErrorCode::Usage => EXIT_USAGE,
ErrorCode::NotFound => EXIT_NOT_FOUND,
ErrorCode::Auth => EXIT_AUTH,
ErrorCode::Forbidden => EXIT_FORBIDDEN,
ErrorCode::RateLimit => EXIT_RATE_LIMIT,
ErrorCode::Network => EXIT_NETWORK,
ErrorCode::Validation | ErrorCode::Conflict => EXIT_VALIDATION,
ErrorCode::Ambiguous => EXIT_AMBIGUOUS,
ErrorCode::Api | ErrorCode::CircuitOpen | ErrorCode::BulkheadFull => EXIT_API,
}
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug)]
pub struct Error {
code: ErrorCode,
message: String,
hint: Option<String>,
http_status: Option<u16>,
retryable: bool,
request_id: Option<String>,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
response_too_large: bool,
body: Option<Box<[u8]>>,
}
impl Error {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Error {
Error {
code,
message: message.into(),
hint: None,
http_status: None,
retryable: false,
request_id: None,
source: None,
response_too_large: false,
body: None,
}
}
pub fn usage(message: impl Into<String>) -> Error {
Error::new(ErrorCode::Usage, message)
}
pub fn usage_with_hint(message: impl Into<String>, hint: impl Into<String>) -> Error {
Error::usage(message).with_hint(hint)
}
pub fn not_found(resource: &str, identifier: impl fmt::Display) -> Error {
Error::new(
ErrorCode::NotFound,
format!("{resource} not found: {identifier}"),
)
.with_status(404)
}
pub fn not_found_with_hint(
resource: &str,
identifier: impl fmt::Display,
hint: impl Into<String>,
) -> Error {
Error::not_found(resource, identifier).with_hint(hint)
}
pub fn auth(message: impl Into<String>) -> Error {
Error::new(ErrorCode::Auth, message).with_status(401)
}
pub fn forbidden(message: impl Into<String>) -> Error {
Error::new(ErrorCode::Forbidden, message).with_status(403)
}
pub fn forbidden_scope() -> Error {
Error::forbidden("Access denied: insufficient scope")
.with_hint("Re-authenticate with full scope")
}
pub fn rate_limit(retry_after: Option<u64>) -> Error {
Error::new(ErrorCode::RateLimit, "Rate limited")
.with_hint(retry_hint(retry_after))
.with_status(429)
.retryable()
}
pub fn rate_limited() -> Error {
Error::new(ErrorCode::RateLimit, "rate limit exceeded")
}
pub fn circuit_open() -> Error {
Error::new(ErrorCode::CircuitOpen, "circuit breaker is open")
}
pub fn cancelled() -> Error {
Error::new(ErrorCode::Network, "operation cancelled")
}
pub fn bulkhead_full() -> Error {
Error::new(ErrorCode::BulkheadFull, "bulkhead is full")
}
pub fn network(source: impl std::error::Error + Send + Sync + 'static) -> Error {
Error::new(ErrorCode::Network, "Network error")
.with_hint(source.to_string())
.retryable()
.with_source(source)
}
pub fn api(status: u16, message: impl Into<String>) -> Error {
Error::new(ErrorCode::Api, message).with_status(status)
}
pub fn conflict(message: impl Into<String>) -> Error {
Error::new(ErrorCode::Conflict, message).with_status(409)
}
pub fn response_too_large(limit: usize, method: &Method, path: &str) -> Error {
Error {
response_too_large: true,
..Error::api(
0,
format!("{method} {path}: response body exceeds {limit} bytes"),
)
}
}
pub(crate) fn refusing(mut self, refusal: Error) -> Error {
self.response_too_large = refusal.response_too_large;
if self.hint.is_none() {
self.hint = Some(refusal.to_string());
}
self.with_source(refusal)
}
pub fn validation(messages: &[String]) -> Error {
let mut message = messages.join("; ");
if message.is_empty() {
message = "validation error".to_string();
}
Error::new(ErrorCode::Validation, message).with_status(422)
}
pub fn ambiguous(resource: &str, matches: &[String]) -> Error {
let hint = match matches.len() {
1..=5 => format!("Did you mean: {}", matches.join(", ")),
_ => "Be more specific".to_string(),
};
Error::new(ErrorCode::Ambiguous, format!("Ambiguous {resource}")).with_hint(hint)
}
pub fn from_std(source: impl std::error::Error + Send + Sync + 'static) -> Error {
Error::new(ErrorCode::Api, source.to_string()).with_source(source)
}
pub fn from_response(
status: StatusCode,
method: &Method,
headers: &HeaderMap,
body: &[u8],
) -> Error {
let error = match status.as_u16() {
401 => Error::auth("authentication required"),
403 if method != Method::GET => Error::forbidden_scope(),
403 => Error::forbidden("access denied"),
404 => Error::new(ErrorCode::NotFound, "resource not found").with_status(404),
422 => Error::new(ErrorCode::Validation, "validation error").with_status(422),
429 => Error::new(ErrorCode::RateLimit, "rate limited - try again later")
.with_hint(retry_hint(retry_after_seconds(headers)))
.with_status(429)
.retryable(),
code => {
let error = Error::api(code, format!("API error: {status}"));
if status.is_server_error() {
error.retryable()
} else {
error
}
}
};
let error = match headers
.get("x-request-id")
.and_then(|value| value.to_str().ok())
{
Some(request_id) => error.with_request_id(request_id),
None => error,
};
let mut error = match (error.hint.is_none(), server_message(body)) {
(true, Some(message)) => error.with_hint(message),
_ => error,
};
if !body.is_empty() {
let kept = body.len().min(MAX_ERROR_BODY_BYTES);
error.body = Some(Box::from(&body[..kept]));
}
error
}
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Error {
self.hint = Some(hint.into());
self
}
#[must_use]
pub fn with_status(mut self, status: u16) -> Error {
self.http_status = Some(status);
self
}
#[must_use]
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Error {
self.request_id = Some(request_id.into());
self
}
#[must_use]
pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Error {
self.source = Some(Box::new(source));
self
}
#[must_use]
pub fn retryable(mut self) -> Error {
self.retryable = true;
self
}
pub fn code(&self) -> ErrorCode {
self.code
}
pub fn is_code(&self, code: ErrorCode) -> bool {
self.code == code
}
pub fn exit_code(&self) -> i32 {
self.code.exit_code()
}
pub fn message(&self) -> &str {
&self.message
}
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
pub fn http_status(&self) -> Option<u16> {
self.http_status
}
pub fn is_retryable(&self) -> bool {
self.retryable
}
pub fn request_id(&self) -> Option<&str> {
self.request_id.as_deref()
}
pub fn is_response_too_large(&self) -> bool {
self.response_too_large
}
pub fn body(&self) -> Option<&[u8]> {
self.body.as_deref()
}
pub fn body_json<T: DeserializeOwned>(&self) -> Option<T> {
serde_json::from_slice(self.body()?).ok()
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.hint {
Some(hint) => write!(f, "{}: {hint}", self.message),
None => f.write_str(&self.message),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_deref()
.map(|source| source as &(dyn std::error::Error + 'static))
}
}
impl From<serde_json::Error> for Error {
fn from(error: serde_json::Error) -> Error {
Error::api(0, "unexpected JSON")
.with_hint(error.to_string())
.with_source(error)
}
}
impl Error {
pub(crate) fn decoding(
status: u16,
request_id: Option<&str>,
error: serde_json::Error,
) -> Error {
let error = Error::new(ErrorCode::Api, "unexpected JSON in the response")
.with_status(status)
.with_hint(error.to_string())
.with_source(error);
match request_id {
Some(request_id) => error.with_request_id(request_id),
None => error,
}
}
pub(crate) fn about(mut self, operation: &str) -> Error {
self.message = format!("{operation}: {}", self.message);
self
}
pub fn timed_out(limit: std::time::Duration) -> Error {
Error::new(
ErrorCode::Network,
format!("operation timed out after {limit:?}"),
)
.retryable()
}
pub fn pagination_capped(max_pages: usize) -> Error {
Error::usage(format!(
"pagination stopped at the page limit of {max_pages} with more pages to read"
))
.with_hint("raise max_pages on the client, or read with a limit")
}
}
impl From<url::ParseError> for Error {
fn from(error: url::ParseError) -> Error {
Error::usage(format!("invalid URL: {error}")).with_source(error)
}
}
fn retry_hint(retry_after: Option<u64>) -> String {
match retry_after {
Some(seconds) if seconds > 0 => format!("Try again in {seconds} seconds"),
_ => "Try again later".to_string(),
}
}
pub(crate) fn retry_after_seconds(headers: &HeaderMap) -> Option<u64> {
let asked = headers.get("retry-after")?.to_str().ok()?.trim();
if let Ok(seconds) = asked.parse::<i64>() {
u64::try_from(seconds).ok()
} else {
let until = chrono::DateTime::parse_from_rfc2822(asked).ok()?;
seconds_until(until.with_timezone(&chrono::Utc), chrono::Utc::now())
}
}
fn seconds_until(
until: chrono::DateTime<chrono::Utc>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<u64> {
let left = until.signed_duration_since(now);
let whole = left.num_seconds();
let started = left > chrono::Duration::seconds(whole);
u64::try_from(if started { whole + 1 } else { whole }).ok()
}
fn server_message(body: &[u8]) -> Option<String> {
let value: serde_json::Value = serde_json::from_slice(body).ok()?;
let message = value
.get("message")
.or_else(|| value.get("error"))?
.as_str()?;
Some(truncate(message, MAX_ERROR_MESSAGE_BYTES))
}
pub(crate) fn truncate(message: &str, limit: usize) -> String {
if message.chars().count() <= limit {
message.to_string()
} else {
let kept: String = message.chars().take(limit - 3).collect();
format!("{kept}...")
}
}
#[cfg(test)]
mod tests {
use super::seconds_until;
use chrono::{DateTime, Duration, Utc};
fn at(millis: i64) -> DateTime<Utc> {
DateTime::from_timestamp_millis(1_700_000_000_000 + millis).unwrap()
}
#[test]
fn a_started_second_counts_as_a_whole_one() {
assert_eq!(seconds_until(at(2_000), at(0)), Some(2));
assert_eq!(seconds_until(at(2_000), at(50)), Some(2));
assert_eq!(seconds_until(at(2_000), at(1_050)), Some(1));
assert_eq!(seconds_until(at(2_000), at(1_999)), Some(1));
assert_eq!(
seconds_until(at(2_000), at(0) - Duration::nanoseconds(1)),
Some(3)
);
assert_eq!(
seconds_until(at(2_000), at(2_000) - Duration::nanoseconds(1)),
Some(1)
);
}
#[test]
fn a_date_already_past_asks_for_no_wait() {
assert_eq!(seconds_until(at(0), at(0)), Some(0));
assert_eq!(seconds_until(at(0), at(500)), Some(0));
assert_eq!(seconds_until(at(0), at(1_500)), None);
assert_eq!(seconds_until(at(0) - Duration::hours(1), at(0)), None);
}
}