1use serde_json::{Value, json};
4
5#[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 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 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 #[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 #[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 #[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}