Skip to main content

elasticctl_core/
error.rs

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