use serde::Deserialize;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("HTTP transport error: {0}")]
Http(#[source] reqwest::Error),
#[error("failed to decode response: {0}")]
Decode(#[source] serde_json::Error),
#[error("failed to encode request: {0}")]
Encode(#[source] serde_json::Error),
#[error("request timed out")]
Timeout,
#[error("API error: {0}")]
Api(ApiError),
#[error("client configuration error: {0}")]
Config(String),
#[error(transparent)]
InvalidIdempotencyKey(#[from] crate::idempotency::IdempotencyKeyError),
#[error(transparent)]
Webhook(#[from] crate::webhook::WebhookError),
}
impl From<reqwest::Error> for Error {
fn from(e: reqwest::Error) -> Self {
if e.is_timeout() {
Self::Timeout
} else {
Self::Http(e)
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ApiError {
pub status: u16,
pub error_code: ErrorCode,
pub message: String,
pub retry_after: Option<u64>,
}
impl ApiError {
pub fn is_retryable(&self) -> bool {
if self.status >= 500 {
return true;
}
matches!(
self.error_code,
ErrorCode::RateLimitExceeded | ErrorCode::Pay001
)
}
pub fn is_unauthorized(&self) -> bool {
self.status == 401 || matches!(self.error_code, ErrorCode::Unauthorized)
}
pub fn is_idempotency_conflict(&self) -> bool {
self.status == 422
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({}): {}",
self.status,
self.error_code.as_str(),
self.message
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorCode {
Unauthorized,
InsufficientScope,
ValidationError,
NotFound,
Conflict,
PaymentFailed,
RateLimitExceeded,
Pay001,
Other(String),
}
impl ErrorCode {
pub fn as_str(&self) -> &str {
match self {
Self::Unauthorized => "unauthorized",
Self::InsufficientScope => "insufficient_scope",
Self::ValidationError => "validation_error",
Self::NotFound => "not_found",
Self::Conflict => "conflict",
Self::PaymentFailed => "payment_failed",
Self::RateLimitExceeded => "rate_limit_exceeded",
Self::Pay001 => "PAY_001",
Self::Other(s) => s.as_str(),
}
}
pub(crate) fn from_str(s: &str) -> Self {
match s {
"unauthorized" => Self::Unauthorized,
"insufficient_scope" => Self::InsufficientScope,
"validation_error" => Self::ValidationError,
"not_found" => Self::NotFound,
"conflict" => Self::Conflict,
"payment_failed" => Self::PaymentFailed,
"rate_limit_exceeded" => Self::RateLimitExceeded,
"PAY_001" => Self::Pay001,
other => Self::Other(other.to_string()),
}
}
}
impl<'de> Deserialize<'de> for ErrorCode {
fn deserialize<D>(d: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(d)?;
Ok(Self::from_str(&s))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_codes_round_trip() {
for s in [
"unauthorized",
"insufficient_scope",
"validation_error",
"not_found",
"conflict",
"payment_failed",
"rate_limit_exceeded",
"PAY_001",
] {
let code = ErrorCode::from_str(s);
assert_eq!(code.as_str(), s);
}
}
#[test]
fn unknown_codes_preserve_raw_string() {
let code = ErrorCode::from_str("future_error_code");
assert!(matches!(code, ErrorCode::Other(_)));
assert_eq!(code.as_str(), "future_error_code");
}
#[test]
fn pay001_is_retryable() {
let err = ApiError {
status: 500,
error_code: ErrorCode::Pay001,
message: "failed to initiate payment".into(),
retry_after: None,
};
assert!(err.is_retryable());
}
#[test]
fn validation_is_not_retryable() {
let err = ApiError {
status: 400,
error_code: ErrorCode::ValidationError,
message: "amount is required".into(),
retry_after: None,
};
assert!(!err.is_retryable());
}
#[test]
fn rate_limited_is_retryable() {
let err = ApiError {
status: 429,
error_code: ErrorCode::RateLimitExceeded,
message: "Too many requests".into(),
retry_after: Some(15),
};
assert!(err.is_retryable());
}
}