use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorBody {
pub message: String,
pub raw: Option<serde_json::Value>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("tango: authentication failed (status 401)")]
Auth {
response: Option<ErrorBody>,
},
#[error("tango: resource not found (status 404)")]
NotFound {
response: Option<ErrorBody>,
},
#[error("tango: invalid request (status 400): {message}")]
Validation {
message: String,
response: Option<ErrorBody>,
},
#[error("tango: rate limit exceeded (status 429); retry after {retry_after}s")]
RateLimit {
retry_after: u32,
limit_type: Option<String>,
response: Option<ErrorBody>,
},
#[error("tango: request timed out after {timeout:?}")]
Timeout {
timeout: Duration,
},
#[error("tango: API error (status {status}): {message}")]
Api {
status: u16,
message: String,
response: Option<ErrorBody>,
},
#[error("tango: HTTP transport error")]
Transport(#[from] reqwest::Error),
#[error("tango: failed to decode response body")]
Decode(#[from] serde_json::Error),
#[error("tango: failed to build request: {0}")]
Build(String),
}
impl Error {
#[must_use]
pub fn status(&self) -> Option<u16> {
match self {
Self::Auth { .. } => Some(401),
Self::NotFound { .. } => Some(404),
Self::Validation { .. } => Some(400),
Self::RateLimit { .. } => Some(429),
Self::Api { status, .. } => Some(*status),
Self::Timeout { .. } | Self::Transport(_) | Self::Decode(_) | Self::Build(_) => None,
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::RateLimit { .. } | Self::Timeout { .. } | Self::Transport(_) => true,
Self::Api { status, .. } => *status == 408 || (500..600).contains(status),
Self::Auth { .. }
| Self::NotFound { .. }
| Self::Validation { .. }
| Self::Decode(_)
| Self::Build(_) => false,
}
}
#[must_use]
pub fn response(&self) -> Option<&ErrorBody> {
match self {
Self::Auth { response, .. }
| Self::NotFound { response, .. }
| Self::Validation { response, .. }
| Self::RateLimit { response, .. }
| Self::Api { response, .. } => response.as_ref(),
Self::Timeout { .. } | Self::Transport(_) | Self::Decode(_) | Self::Build(_) => None,
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_codes() {
assert_eq!(Error::Auth { response: None }.status(), Some(401));
assert_eq!(Error::NotFound { response: None }.status(), Some(404));
assert_eq!(
Error::Validation {
message: "x".into(),
response: None
}
.status(),
Some(400)
);
assert_eq!(
Error::RateLimit {
retry_after: 1,
limit_type: None,
response: None
}
.status(),
Some(429)
);
assert_eq!(
Error::Api {
status: 502,
message: "x".into(),
response: None
}
.status(),
Some(502)
);
assert_eq!(
Error::Timeout {
timeout: Duration::from_secs(1)
}
.status(),
None
);
}
#[test]
fn retry_decisions() {
assert!(Error::RateLimit {
retry_after: 0,
limit_type: None,
response: None
}
.is_retryable());
assert!(Error::Timeout {
timeout: Duration::from_secs(1)
}
.is_retryable());
assert!(Error::Api {
status: 502,
message: "x".into(),
response: None
}
.is_retryable());
assert!(Error::Api {
status: 408,
message: "x".into(),
response: None
}
.is_retryable());
assert!(!Error::Auth { response: None }.is_retryable());
assert!(!Error::NotFound { response: None }.is_retryable());
assert!(!Error::Validation {
message: "x".into(),
response: None
}
.is_retryable());
assert!(!Error::Api {
status: 418,
message: "x".into(),
response: None
}
.is_retryable());
assert!(!Error::Build("x".into()).is_retryable());
}
}