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 = 10 * 1024;
pub const MAX_ERROR_MESSAGE_BYTES: usize = 500;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorCode {
Usage,
NotFound,
AuthRequired,
Forbidden,
RateLimit,
Network,
ApiError,
Validation,
Ambiguous,
}
impl ErrorCode {
pub fn as_str(&self) -> &'static str {
match self {
ErrorCode::Usage => "usage",
ErrorCode::NotFound => "not_found",
ErrorCode::AuthRequired => "auth_required",
ErrorCode::Forbidden => "forbidden",
ErrorCode::RateLimit => "rate_limit",
ErrorCode::Network => "network",
ErrorCode::ApiError => "api_error",
ErrorCode::Validation => "validation",
ErrorCode::Ambiguous => "ambiguous",
}
}
pub fn exit_code(&self) -> i32 {
match self {
ErrorCode::Usage => EXIT_USAGE,
ErrorCode::NotFound => EXIT_NOT_FOUND,
ErrorCode::AuthRequired => EXIT_AUTH,
ErrorCode::Forbidden => EXIT_FORBIDDEN,
ErrorCode::RateLimit => EXIT_RATE_LIMIT,
ErrorCode::Network => EXIT_NETWORK,
ErrorCode::ApiError => EXIT_API,
ErrorCode::Validation => EXIT_VALIDATION,
ErrorCode::Ambiguous => EXIT_AMBIGUOUS,
}
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Refusal {
CircuitOpen,
BulkheadFull,
RateLimited,
}
#[derive(Debug)]
pub struct Error {
code: ErrorCode,
message: String,
hint: Option<String>,
http_status: Option<u16>,
retryable: bool,
request_id: Option<String>,
refusal: Option<Refusal>,
cancelled: bool,
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,
refusal: None,
cancelled: false,
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 auth(message: impl Into<String>) -> Error {
Error::new(ErrorCode::AuthRequired, 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").refusing_as(Refusal::RateLimited)
}
pub fn circuit_open() -> Error {
Error::new(ErrorCode::ApiError, "circuit breaker is open").refusing_as(Refusal::CircuitOpen)
}
pub fn bulkhead_full() -> Error {
Error::new(ErrorCode::ApiError, "bulkhead is full").refusing_as(Refusal::BulkheadFull)
}
pub fn cancelled() -> Error {
Error {
cancelled: true,
..Error::new(ErrorCode::Network, "operation cancelled")
}
}
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::ApiError, message).with_status(status)
}
pub fn response_too_large(limit: usize, method: &Method, path: &str) -> Error {
Error {
response_too_large: true,
..Error::new(
ErrorCode::ApiError,
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::ApiError, 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 failed"),
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 failed").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() {
match retry_after_seconds(headers) {
Some(seconds) => error.with_hint(retry_hint(Some(seconds))).retryable(),
None => 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
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Error {
self.hint = Some(hint.into());
self
}
pub fn with_status(mut self, status: u16) -> Error {
self.http_status = Some(status);
self
}
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Error {
self.request_id = Some(request_id.into());
self
}
pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Error {
self.source = Some(Box::new(source));
self
}
pub fn retryable(mut self) -> Error {
self.retryable = true;
self
}
fn refusing_as(mut self, refusal: Refusal) -> Error {
self.refusal = Some(refusal);
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 refusal(&self) -> Option<Refusal> {
self.refusal
}
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
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::new(ErrorCode::ApiError, "unexpected JSON")
.with_hint(error.to_string())
.with_source(error)
}
}
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.saturating_sub(3)).collect();
format!("{kept}...")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
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)
);
}
#[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);
}
#[test]
fn retry_after_reads_seconds_and_dates() {
let mut headers = HeaderMap::new();
headers.insert("retry-after", "7".parse().unwrap());
assert_eq!(retry_after_seconds(&headers), Some(7));
headers.insert(
"retry-after",
"Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap(),
);
assert_eq!(retry_after_seconds(&headers), None);
headers.insert("retry-after", "soon".parse().unwrap());
assert_eq!(retry_after_seconds(&headers), None);
}
#[test]
fn every_code_has_an_exit_status_and_a_name() {
let codes = [
(ErrorCode::Usage, "usage", 1),
(ErrorCode::NotFound, "not_found", 2),
(ErrorCode::AuthRequired, "auth_required", 3),
(ErrorCode::Forbidden, "forbidden", 4),
(ErrorCode::RateLimit, "rate_limit", 5),
(ErrorCode::Network, "network", 6),
(ErrorCode::ApiError, "api_error", 7),
(ErrorCode::Validation, "validation", 9),
(ErrorCode::Ambiguous, "ambiguous", 8),
];
for (code, name, exit) in codes {
assert_eq!(code.as_str(), name);
assert_eq!(code.exit_code(), exit);
}
}
#[test]
fn refusals_carry_no_status_and_say_which_layer() {
assert_eq!(Error::circuit_open().refusal(), Some(Refusal::CircuitOpen));
assert_eq!(
Error::bulkhead_full().refusal(),
Some(Refusal::BulkheadFull)
);
assert_eq!(Error::rate_limited().refusal(), Some(Refusal::RateLimited));
assert_eq!(Error::rate_limited().code(), ErrorCode::RateLimit);
assert_eq!(Error::circuit_open().http_status(), None);
assert_eq!(Error::api(500, "boom").refusal(), None);
}
}