#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("configuration error: {0}")]
Configuration(String),
#[error("authentication error: {message}")]
Authentication {
message: String,
status: u16,
},
#[error("not found: {0}")]
NotFound(String),
#[error("validation error: {message}")]
Validation {
message: String,
field: Option<String>,
},
#[error("API error (HTTP {status}): {message}")]
Api {
message: String,
status: u16,
body: Option<String>,
},
#[error("network error: {0}")]
Network(String),
#[error(transparent)]
Http(#[from] reqwest::Error),
}
impl Error {
#[must_use]
pub fn status(&self) -> Option<u16> {
match self {
Self::Authentication { status, .. } | Self::Api { status, .. } => Some(*status),
Self::NotFound(_) => Some(404),
Self::Validation { .. } => Some(400),
_ => None,
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Network(_) | Self::Http(_) => true,
Self::Api { status, .. } => matches!(status, 429 | 500 | 502 | 503 | 504),
_ => false,
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub(crate) fn extract_error_message(body: &str) -> String {
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
return body.to_string();
};
value
.get("data")
.and_then(serde_json::Value::as_str)
.or_else(|| value.get("message").and_then(serde_json::Value::as_str))
.map(String::from)
.unwrap_or_else(|| body.to_string())
}
pub(crate) fn map_status_error(status: u16, body: String) -> Error {
let message = extract_error_message(&body);
match status {
401 | 403 => Error::Authentication { message, status },
404 => Error::NotFound(message),
400 => Error::Validation {
message,
field: None,
},
_ => Error::Api {
message,
status,
body: Some(body),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_codes_are_correct() {
let auth = Error::Authentication {
message: "bad".into(),
status: 401,
};
assert_eq!(auth.status(), Some(401));
let not_found = Error::NotFound("gone".into());
assert_eq!(not_found.status(), Some(404));
let validation = Error::Validation {
message: "bad field".into(),
field: Some("name".into()),
};
assert_eq!(validation.status(), Some(400));
let api = Error::Api {
message: "fail".into(),
status: 500,
body: None,
};
assert_eq!(api.status(), Some(500));
let config = Error::Configuration("missing url".into());
assert_eq!(config.status(), None);
let network = Error::Network("timeout".into());
assert_eq!(network.status(), None);
}
#[test]
fn retryable_classification() {
assert!(Error::Network("timeout".into()).is_retryable());
assert!(Error::Api {
message: String::new(),
status: 503,
body: None
}
.is_retryable());
assert!(!Error::Authentication {
message: String::new(),
status: 401
}
.is_retryable());
assert!(!Error::NotFound(String::new()).is_retryable());
assert!(!Error::Configuration(String::new()).is_retryable());
}
#[test]
fn extract_message_from_the_engine_envelope() {
let body = r#"{"status":404,"data":"index does not exist"}"#;
assert_eq!(extract_error_message(body), "index does not exist");
}
#[test]
fn extract_message_from_a_message_field() {
let body = r#"{"message": "index not found"}"#;
assert_eq!(extract_error_message(body), "index not found");
}
#[test]
fn extract_message_fallback() {
assert_eq!(extract_error_message("plain text"), "plain text");
}
#[test]
fn map_status_error_variants() {
let e = map_status_error(401, r#"{"message":"unauthorized"}"#.into());
assert!(matches!(e, Error::Authentication { status: 401, .. }));
let e = map_status_error(404, r#"{"message":"not found"}"#.into());
assert!(matches!(e, Error::NotFound(_)));
let e = map_status_error(400, r#"{"message":"bad request"}"#.into());
assert!(matches!(e, Error::Validation { .. }));
let e = map_status_error(500, "server error".into());
assert!(matches!(e, Error::Api { status: 500, .. }));
}
}