use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Api(Box<ApiError>),
#[error("connection error: {0}")]
Connection(#[source] reqwest::Error),
#[error("request timed out")]
Timeout,
#[error("failed to decode response body: {0}")]
Decode(#[source] serde_json::Error),
#[error("invalid client configuration: {0}")]
Config(String),
#[error("failed to encode request: {0}")]
Encode(String),
#[error("event stream error: {0}")]
Stream(String),
}
impl From<ApiError> for Error {
fn from(error: ApiError) -> Self {
Error::Api(Box::new(error))
}
}
impl Error {
pub fn status(&self) -> Option<u16> {
match self {
Error::Api(err) => Some(err.status),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
match self {
Error::Api(err) => err.is_retryable(),
Error::Connection(_) | Error::Timeout => true,
_ => false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Problem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(
rename = "correlationId",
default,
skip_serializing_if = "Option::is_none"
)]
pub correlation_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<FieldError>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldError {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApiErrorKind {
BadRequest,
Authentication,
PermissionDenied,
NotFound,
Conflict,
Gone,
PayloadTooLarge,
UnprocessableEntity,
RateLimit,
ServiceUnavailable,
Server,
Other,
}
#[derive(Debug, Clone)]
pub struct ApiError {
pub status: u16,
pub problem: Problem,
pub headers: HashMap<String, String>,
}
impl ApiError {
pub fn kind(&self) -> ApiErrorKind {
match self.status {
400 => ApiErrorKind::BadRequest,
401 => ApiErrorKind::Authentication,
403 => ApiErrorKind::PermissionDenied,
404 => ApiErrorKind::NotFound,
409 => ApiErrorKind::Conflict,
410 => ApiErrorKind::Gone,
413 => ApiErrorKind::PayloadTooLarge,
422 => ApiErrorKind::UnprocessableEntity,
429 => ApiErrorKind::RateLimit,
503 => ApiErrorKind::ServiceUnavailable,
status if status >= 500 => ApiErrorKind::Server,
_ => ApiErrorKind::Other,
}
}
pub fn correlation_id(&self) -> Option<&str> {
self.problem
.correlation_id
.as_deref()
.or_else(|| self.headers.get("x-correlation-id").map(String::as_str))
}
pub fn retry_after_seconds(&self) -> Option<f64> {
self.headers.get("retry-after")?.parse().ok()
}
pub fn rate_limit_remaining(&self) -> Option<i64> {
self.headers.get("x-ratelimit-remaining")?.parse().ok()
}
pub fn is_retryable(&self) -> bool {
matches!(self.status, 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let title = self.problem.title.as_deref().unwrap_or("HTTP error");
write!(f, "{} {}", self.status, title)?;
if let Some(detail) = &self.problem.detail {
write!(f, " — {detail}")?;
}
if let Some(correlation) = self.correlation_id() {
write!(f, " (correlationId: {correlation})")?;
}
Ok(())
}
}
impl std::error::Error for ApiError {}