use serde::Deserialize;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorInfo {
pub code: i64,
#[serde(default)]
pub message: String,
#[serde(default)]
pub status_code: u16,
#[serde(default)]
pub href: Option<String>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("HTTP transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("failed to decode response: {0}")]
Decode(String),
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("Ably API error {}: {message}", .info.code, message = .info.message)]
Api {
status: u16,
info: ErrorInfo,
},
}
#[derive(Deserialize)]
struct Envelope {
error: ErrorInfo,
}
impl Error {
pub(crate) fn from_api_body(status: u16, body: &[u8]) -> Self {
let info = serde_json::from_slice::<Envelope>(body)
.map(|e| e.error)
.unwrap_or_else(|_| ErrorInfo {
code: 0,
message: String::from_utf8_lossy(body).into_owned(),
status_code: status,
href: None,
});
Error::Api { status, info }
}
pub fn status(&self) -> Option<u16> {
match self {
Error::Api { status, .. } => Some(*status),
Error::Transport(e) => e.status().map(|s| s.as_u16()),
Error::Decode(_) | Error::InvalidRequest(_) => None,
}
}
pub fn info(&self) -> Option<&ErrorInfo> {
match self {
Error::Api { info, .. } => Some(info),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
match self {
Error::Transport(e) => e.is_timeout() || e.is_connect(),
Error::Api { status, .. } => *status == 429 || (500..=599).contains(status),
Error::Decode(_) | Error::InvalidRequest(_) => false,
}
}
pub fn is_not_found(&self) -> bool {
matches!(self, Error::Api { info, .. } if info.code == 40400)
}
pub fn is_rejected_by_rule(&self) -> bool {
matches!(self, Error::Api { info, .. } if info.code == 42211)
}
pub fn is_rejected_by_moderation(&self) -> bool {
matches!(self, Error::Api { info, .. } if info.code == 42213)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ably_envelope() {
let body = r#"{"error":{"code":40400,"message":"not found","statusCode":404}}"#;
let e = Error::from_api_body(404, body.as_bytes());
assert_eq!(e.status(), Some(404));
assert!(e.is_not_found());
assert!(!e.is_retryable());
}
#[test]
fn server_error_is_retryable() {
let e = Error::from_api_body(503, b"{}");
assert!(e.is_retryable());
}
#[test]
fn rate_limit_is_retryable() {
let e = Error::from_api_body(429, b"{}");
assert!(e.is_retryable());
}
#[test]
fn non_json_body_is_preserved_as_message() {
let e = Error::from_api_body(500, b"upstream boom");
assert_eq!(e.status(), Some(500));
assert!(e.is_retryable());
assert_eq!(e.info().map(|i| i.message.as_str()), Some("upstream boom"));
}
#[test]
fn code_predicates() {
assert!(
Error::from_api_body(
422,
br#"{"error":{"code":42211,"message":"x","statusCode":422}}"#
)
.is_rejected_by_rule()
);
assert!(
Error::from_api_body(
422,
br#"{"error":{"code":42213,"message":"x","statusCode":422}}"#
)
.is_rejected_by_moderation()
);
}
}