Skip to main content

elasticctl_core/
error.rs

1use serde_json::{Value, json};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ErrorKind {
5    Auth,
6    Permission,
7    NotFound,
8    Conflict,
9    Unsupported,
10    Http,
11    Connection,
12    Timeout,
13    Error,
14}
15
16impl ErrorKind {
17    pub fn as_str(&self) -> &'static str {
18        match self {
19            Self::Auth => "auth",
20            Self::Permission => "permission",
21            Self::NotFound => "not_found",
22            Self::Conflict => "conflict",
23            Self::Unsupported => "unsupported",
24            Self::Http => "http",
25            Self::Connection => "connection",
26            Self::Timeout => "timeout",
27            Self::Error => "error",
28        }
29    }
30
31    pub fn from_status(status: u16) -> Self {
32        match status {
33            401 => Self::Auth,
34            403 => Self::Permission,
35            404 => Self::NotFound,
36            409 => Self::Conflict,
37            _ => Self::Http,
38        }
39    }
40}
41
42#[derive(Debug, Clone, thiserror::Error)]
43#[error("{message}")]
44pub struct Error {
45    pub kind: ErrorKind,
46    pub http_status: Option<u16>,
47    pub message: String,
48}
49
50impl Error {
51    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
52        Self {
53            kind,
54            http_status: None,
55            message: message.into(),
56        }
57    }
58
59    pub fn with_status(kind: ErrorKind, status: u16, message: impl Into<String>) -> Self {
60        Self {
61            kind,
62            http_status: Some(status),
63            message: message.into(),
64        }
65    }
66
67    pub fn from_response_body(status: u16, body: &str) -> Self {
68        let kind = ErrorKind::from_status(status);
69        let message = serde_json::from_str::<Value>(body)
70            .ok()
71            .and_then(|v| {
72                v.get("message")
73                    .or_else(|| v.get("error").and_then(|e| e.get("reason")))
74                    .and_then(Value::as_str)
75                    .map(str::to_owned)
76            })
77            .unwrap_or_else(|| {
78                let trimmed = body.trim();
79                if trimmed.is_empty() {
80                    format!("HTTP {status}")
81                } else {
82                    format!("HTTP {status}: {trimmed}")
83                }
84            });
85        Self {
86            kind,
87            http_status: Some(status),
88            message,
89        }
90    }
91
92    pub fn to_envelope(&self) -> Value {
93        let mut inner = json!({ "kind": self.kind.as_str(), "message": self.message });
94        if let Some(status) = self.http_status {
95            inner["http_status"] = json!(status);
96        }
97        json!({ "error": inner })
98    }
99}
100
101pub type Result<T> = std::result::Result<T, Error>;
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn kind_maps_from_http_status() {
109        assert_eq!(ErrorKind::from_status(401), ErrorKind::Auth);
110        assert_eq!(ErrorKind::from_status(403), ErrorKind::Permission);
111        assert_eq!(ErrorKind::from_status(404), ErrorKind::NotFound);
112        assert_eq!(ErrorKind::from_status(409), ErrorKind::Conflict);
113        assert_eq!(ErrorKind::from_status(500), ErrorKind::Http);
114        assert_eq!(ErrorKind::from_status(418), ErrorKind::Http);
115    }
116
117    #[test]
118    fn kind_str_values_are_the_documented_taxonomy() {
119        assert_eq!(ErrorKind::Auth.as_str(), "auth");
120        assert_eq!(ErrorKind::Permission.as_str(), "permission");
121        assert_eq!(ErrorKind::NotFound.as_str(), "not_found");
122        assert_eq!(ErrorKind::Conflict.as_str(), "conflict");
123        assert_eq!(ErrorKind::Unsupported.as_str(), "unsupported");
124        assert_eq!(ErrorKind::Http.as_str(), "http");
125        assert_eq!(ErrorKind::Connection.as_str(), "connection");
126        assert_eq!(ErrorKind::Timeout.as_str(), "timeout");
127        assert_eq!(ErrorKind::Error.as_str(), "error");
128    }
129
130    #[test]
131    fn parses_the_kibana_error_envelope() {
132        let body = r#"{"statusCode":400,"error":"Bad Request","message":"rule_id already exists"}"#;
133        let err = Error::from_response_body(400, body);
134        assert_eq!(err.kind, ErrorKind::Http);
135        assert_eq!(err.http_status, Some(400));
136        assert_eq!(err.message, "rule_id already exists");
137    }
138
139    #[test]
140    fn parses_the_cloud_edge_proxy_envelope() {
141        let body = r#"{"ok":false,"message":"Unknown resource."}"#;
142        let err = Error::from_response_body(404, body);
143        assert_eq!(err.kind, ErrorKind::NotFound);
144        assert_eq!(err.message, "Unknown resource.");
145    }
146
147    #[test]
148    fn parses_the_elasticsearch_error_envelope() {
149        let body = r#"{"error":{"root_cause":[{"type":"x_content_parse_exception","reason":"[1:68] [esql/query] unknown field [search_after]"}],"type":"x_content_parse_exception","reason":"[1:68] [esql/query] unknown field [search_after]"},"status":400}"#;
150        let err = Error::from_response_body(400, body);
151        assert_eq!(err.kind, ErrorKind::Http);
152        assert_eq!(err.http_status, Some(400));
153        assert_eq!(
154            err.message,
155            "[1:68] [esql/query] unknown field [search_after]"
156        );
157    }
158
159    #[test]
160    fn falls_back_to_the_raw_body_when_it_is_not_json() {
161        let err = Error::from_response_body(502, "<html>bad gateway</html>");
162        assert_eq!(err.kind, ErrorKind::Http);
163        assert_eq!(err.http_status, Some(502));
164        assert!(err.message.contains("bad gateway"));
165    }
166
167    #[test]
168    fn falls_back_when_json_has_no_message_field() {
169        let err = Error::from_response_body(500, r#"{"unexpected":true}"#);
170        assert_eq!(err.kind, ErrorKind::Http);
171        assert!(!err.message.is_empty());
172    }
173
174    #[test]
175    fn envelope_is_the_documented_shape() {
176        let err = Error::with_status(ErrorKind::Permission, 403, "nope");
177        let env = err.to_envelope();
178        assert_eq!(env["error"]["kind"], "permission");
179        assert_eq!(env["error"]["http_status"], 403);
180        assert_eq!(env["error"]["message"], "nope");
181    }
182
183    #[test]
184    fn envelope_omits_http_status_when_absent() {
185        let err = Error::new(ErrorKind::Connection, "dns failure");
186        let env = err.to_envelope();
187        assert_eq!(env["error"]["kind"], "connection");
188        assert!(env["error"].get("http_status").is_none());
189    }
190}