use std::collections::BTreeMap;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestContext {
pub method: String,
pub path: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiErrorKind {
Authentication,
Forbidden,
NotFound,
Validation,
Conflict,
QuotaExceeded,
Server,
Api,
}
#[derive(Debug, Clone)]
pub struct ApiError {
pub kind: ApiErrorKind,
pub message: String,
pub status_code: Option<u16>,
pub error_code: Option<String>,
pub body: Option<BTreeMap<String, Value>>,
pub request: Option<RequestContext>,
pub retry_after_seconds: Option<f64>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{}", .0.message)]
Api(Box<ApiError>),
#[error("{message}")]
Network {
message: String,
request: Option<RequestContext>,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("{message}")]
Timeout {
message: String,
request: Option<RequestContext>,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
}
impl Error {
pub(crate) fn validation(message: impl Into<String>) -> Self {
Error::Api(Box::new(ApiError {
kind: ApiErrorKind::Validation,
message: message.into(),
status_code: None,
error_code: None,
body: None,
request: None,
retry_after_seconds: None,
}))
}
pub(crate) fn network(
message: impl Into<String>,
request: Option<RequestContext>,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
) -> Self {
Error::Network {
message: message.into(),
request,
source,
}
}
pub(crate) fn timeout(
message: impl Into<String>,
request: Option<RequestContext>,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
) -> Self {
Error::Timeout {
message: message.into(),
request,
source,
}
}
pub fn status_code(&self) -> Option<u16> {
match self {
Error::Api(a) => a.status_code,
_ => None,
}
}
pub fn error_code(&self) -> Option<&str> {
match self {
Error::Api(a) => a.error_code.as_deref(),
_ => None,
}
}
pub fn retry_after_seconds(&self) -> Option<f64> {
match self {
Error::Api(a) => a.retry_after_seconds,
_ => None,
}
}
pub fn body(&self) -> Option<&BTreeMap<String, Value>> {
match self {
Error::Api(a) => a.body.as_ref(),
_ => None,
}
}
pub fn request(&self) -> Option<&RequestContext> {
match self {
Error::Api(a) => a.request.as_ref(),
Error::Network { request, .. } | Error::Timeout { request, .. } => request.as_ref(),
}
}
pub fn kind(&self) -> Option<ApiErrorKind> {
match self {
Error::Api(a) => Some(a.kind),
_ => None,
}
}
fn is_kind(&self, k: ApiErrorKind) -> bool {
matches!(self, Error::Api(a) if a.kind == k)
}
pub fn is_authentication(&self) -> bool {
self.is_kind(ApiErrorKind::Authentication)
}
pub fn is_forbidden(&self) -> bool {
self.is_kind(ApiErrorKind::Forbidden)
}
pub fn is_not_found(&self) -> bool {
self.is_kind(ApiErrorKind::NotFound)
}
pub fn is_validation(&self) -> bool {
self.is_kind(ApiErrorKind::Validation)
}
pub fn is_conflict(&self) -> bool {
self.is_kind(ApiErrorKind::Conflict)
}
pub fn is_quota_exceeded(&self) -> bool {
self.is_kind(ApiErrorKind::QuotaExceeded)
}
pub fn is_server(&self) -> bool {
self.is_kind(ApiErrorKind::Server)
}
pub fn is_network(&self) -> bool {
matches!(self, Error::Network { .. })
}
pub fn is_timeout(&self) -> bool {
matches!(self, Error::Timeout { .. })
}
}
fn kind_for_status(status: u16) -> ApiErrorKind {
match status {
400 => ApiErrorKind::Validation,
401 => ApiErrorKind::Authentication,
403 => ApiErrorKind::Forbidden,
404 => ApiErrorKind::NotFound,
409 => ApiErrorKind::Conflict,
429 => ApiErrorKind::QuotaExceeded,
s if s >= 500 => ApiErrorKind::Server,
_ => ApiErrorKind::Api,
}
}
fn describe_request(ctx: Option<&RequestContext>) -> String {
match ctx {
Some(c) => format!(" on {} {}", c.method, c.path),
None => String::new(),
}
}
pub(crate) fn error_for_status(
status_code: u16,
body: Option<BTreeMap<String, Value>>,
request: Option<RequestContext>,
retry_after_seconds: Option<f64>,
) -> Result<(), Error> {
if (200..300).contains(&status_code) {
return Ok(());
}
let b = body.clone().unwrap_or_default();
let error_code = b
.get("error")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "unknown".to_string());
let server_message = b.get("message").and_then(Value::as_str).map(str::to_string);
let message = if let Some(m) = server_message {
m
} else if let (429, Some(secs)) = (status_code, retry_after_seconds) {
format!(
"HTTP 429{} — rate limited, retry after {}s",
describe_request(request.as_ref()),
secs
)
} else {
format!(
"HTTP {}{} (no response body)",
status_code,
describe_request(request.as_ref())
)
};
Err(Error::Api(Box::new(ApiError {
kind: kind_for_status(status_code),
message,
status_code: Some(status_code),
error_code: Some(error_code),
body: Some(b),
request,
retry_after_seconds,
})))
}