1use rustlavel_core::{Error, Json};
16
17pub type Result<T> = std::result::Result<T, SearchError>;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum SearchError {
29 IndexNotFound { index: String },
36 IndexAlreadyExists { index: String },
41 MapperParsing { kind: String, reason: String },
50 VersionConflict { index: String, id: String, reason: String },
57 Overloaded { kind: String, reason: String },
63 Unauthorized { reason: String },
65 Forbidden { reason: String },
70 BadRequest { kind: String, reason: String },
73 Unavailable { status: u16, reason: String },
76 BulkPartialFailure { failed: usize, total: usize, first: String },
86 Unexpected { status: u16, kind: String, reason: String },
88 Transport(String),
90 Malformed { context: String, message: String },
97}
98
99impl SearchError {
100 pub fn from_response(status: u16, context: &str, body: &str) -> SearchError {
106 let parsed = Json::parse(body).ok();
107 let error = parsed.as_ref().and_then(|json| json.get("error"));
108
109 let kind = error.and_then(|e| e.get("type")).and_then(Json::as_str).unwrap_or("").to_string();
110 let reason = reason_of(error, body);
111 let index = error
112 .and_then(|e| e.get("index"))
113 .and_then(Json::as_str)
114 .map(str::to_string)
115 .unwrap_or_else(|| context.to_string());
116
117 match kind.as_str() {
122 "index_not_found_exception" => SearchError::IndexNotFound { index },
123 "resource_already_exists_exception" => SearchError::IndexAlreadyExists { index },
124 "mapper_parsing_exception"
125 | "document_parsing_exception"
126 | "strict_dynamic_mapping_exception"
127 | "mapper_exception" => SearchError::MapperParsing { kind, reason },
128 "version_conflict_engine_exception" => SearchError::VersionConflict {
129 index,
130 id: error
131 .and_then(|e| e.get("id"))
132 .and_then(Json::as_str)
133 .map(str::to_string)
134 .unwrap_or_default(),
135 reason,
136 },
137 "circuit_breaking_exception"
140 | "es_rejected_execution_exception"
141 | "cluster_block_exception" => SearchError::Overloaded { kind, reason },
142 "security_exception" if status == 403 => SearchError::Forbidden { reason },
143 "security_exception" => SearchError::Unauthorized { reason },
144 _ => match status {
145 400 => SearchError::BadRequest { kind, reason },
146 401 => SearchError::Unauthorized { reason },
147 403 => SearchError::Forbidden { reason },
148 404 => SearchError::IndexNotFound { index },
149 409 => SearchError::VersionConflict { index, id: String::new(), reason },
150 429 => SearchError::Overloaded { kind, reason },
151 502..=504 => SearchError::Unavailable { status, reason },
152 other => SearchError::Unexpected { status: other, kind, reason },
153 },
154 }
155 }
156
157 pub fn is_retryable(&self) -> bool {
169 matches!(
170 self,
171 SearchError::Overloaded { .. }
172 | SearchError::Unavailable { .. }
173 | SearchError::Transport(_)
174 )
175 }
176
177 pub fn is_conflict(&self) -> bool {
179 matches!(self, SearchError::VersionConflict { .. })
180 }
181
182 pub fn is_index_not_found(&self) -> bool {
183 matches!(self, SearchError::IndexNotFound { .. })
184 }
185
186 pub fn is_auth_failure(&self) -> bool {
188 matches!(self, SearchError::Unauthorized { .. } | SearchError::Forbidden { .. })
189 }
190
191 pub fn kind(&self) -> &str {
197 match self {
198 SearchError::IndexNotFound { .. } => "index_not_found_exception",
199 SearchError::IndexAlreadyExists { .. } => "resource_already_exists_exception",
200 SearchError::VersionConflict { .. } => "version_conflict_engine_exception",
201 SearchError::MapperParsing { kind, .. }
202 | SearchError::Overloaded { kind, .. }
203 | SearchError::BadRequest { kind, .. }
204 | SearchError::Unexpected { kind, .. } => kind,
205 _ => "",
206 }
207 }
208}
209
210fn reason_of(error: Option<&Json>, body: &str) -> String {
216 let Some(error) = error else {
217 return fallback(body);
218 };
219
220 if let Some(text) = error.as_str() {
223 return truncate(text);
224 }
225
226 let reason = error.get("reason").and_then(Json::as_str).unwrap_or("");
227 let cause = error.get("caused_by.reason").and_then(Json::as_str).unwrap_or("");
228
229 let joined = if cause.is_empty() || reason.contains(cause) {
230 reason.to_string()
231 } else {
232 format!("{reason}: {cause}")
233 };
234
235 if joined.is_empty() { fallback(body) } else { truncate(&joined) }
236}
237
238fn fallback(body: &str) -> String {
243 let trimmed = body.trim();
244 if trimmed.is_empty() {
245 "the cluster sent no explanation".to_string()
246 } else {
247 truncate(trimmed)
248 }
249}
250
251fn truncate(text: &str) -> String {
256 if text.chars().count() <= 300 {
257 return text.to_string();
258 }
259 let cut: String = text.chars().take(300).collect();
260 format!("{cut}…")
261}
262
263impl std::fmt::Display for SearchError {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 match self {
266 SearchError::IndexNotFound { index } => write!(
267 f,
268 "no index called `{index}`. Check the spelling, or create it before searching it — \
269 a missing index is not an empty one."
270 ),
271 SearchError::IndexAlreadyExists { index } => {
272 write!(f, "the index `{index}` already exists")
273 }
274 SearchError::MapperParsing { kind, reason } => write!(
275 f,
276 "the document does not fit the mapping ({kind}): {reason}. Retrying will not \
277 help; either the document or the mapping has to change."
278 ),
279 SearchError::VersionConflict { index, id, reason } => write!(
280 f,
281 "`{id}` in `{index}` changed since it was read: {reason}. Read it again and \
282 reapply the change — sending this version again would overwrite the newer one."
283 ),
284 SearchError::Overloaded { kind, reason } => write!(
285 f,
286 "the cluster refused the request to protect itself ({kind}): {reason}. \
287 It was not performed; back off and try again."
288 ),
289 SearchError::Unauthorized { reason } => write!(
290 f,
291 "the cluster rejected the credentials: {reason}. Set a user and password, or an \
292 API key, on the client."
293 ),
294 SearchError::Forbidden { reason } => write!(
295 f,
296 "these credentials are not allowed to do that: {reason}. The password is \
297 accepted; the role is missing a privilege."
298 ),
299 SearchError::BadRequest { kind, reason } => {
300 write!(f, "the cluster rejected the request ({kind}): {reason}")
301 }
302 SearchError::Unavailable { status, reason } => {
303 write!(f, "the cluster is not answering ({status}): {reason}")
304 }
305 SearchError::BulkPartialFailure { failed, total, first } => write!(
306 f,
307 "{failed} of {total} bulk items were not written, starting with: {first}. \
308 The request itself answered 200 — inspect the report for the rest."
309 ),
310 SearchError::Unexpected { status, kind, reason } => {
311 write!(f, "the cluster answered {status} ({kind}): {reason}")
312 }
313 SearchError::Transport(message) => {
314 write!(f, "could not reach the cluster: {message}")
315 }
316 SearchError::Malformed { context, message } => write!(
317 f,
318 "the reply to `{context}` was not what this client expected: {message}"
319 ),
320 }
321 }
322}
323
324impl std::error::Error for SearchError {}
325
326impl From<SearchError> for Error {
327 fn from(error: SearchError) -> Error {
328 Error::msg(error.to_string())
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
339 fn a_missing_index_is_told_apart_from_a_missing_document() {
340 let body = r#"{"error":{"root_cause":[{"type":"index_not_found_exception","reason":"no such index [nope]","resource.type":"index_or_alias","resource.id":"nope","index_uuid":"_na_","index":"nope"}],"type":"index_not_found_exception","reason":"no such index [nope]","resource.type":"index_or_alias","resource.id":"nope","index_uuid":"_na_","index":"nope"},"status":404}"#;
341
342 assert_eq!(
343 SearchError::from_response(404, "nope", body),
344 SearchError::IndexNotFound { index: "nope".into() }
345 );
346 }
347
348 #[test]
349 fn a_mapping_failure_keeps_the_cause_because_the_reason_alone_names_no_problem() {
350 let body = r#"{"error":{"root_cause":[{"type":"document_parsing_exception","reason":"[1:14] failed to parse field [price] of type [long] in document with id 'x'. Preview of field's value: 'cheap'"}],"type":"document_parsing_exception","reason":"[1:14] failed to parse field [price] of type [long] in document with id 'x'. Preview of field's value: 'cheap'","caused_by":{"type":"illegal_argument_exception","reason":"For input string: \"cheap\""}},"status":400}"#;
351
352 let error = SearchError::from_response(400, "products/x", body);
353
354 assert!(matches!(error, SearchError::MapperParsing { .. }));
355 let text = error.to_string();
356 assert!(text.contains("field [price]"), "got {text}");
357 assert!(text.contains("For input string"), "the cause is the useful half: {text}");
358 }
359
360 #[test]
361 fn a_mapping_error_is_never_retried_and_a_rejected_write_always_is() {
362 let mapping = SearchError::MapperParsing { kind: "x".into(), reason: "y".into() };
365 let rejected = SearchError::Overloaded { kind: "x".into(), reason: "y".into() };
366
367 assert!(!mapping.is_retryable());
368 assert!(rejected.is_retryable());
369 }
370
371 #[test]
372 fn a_version_conflict_is_not_retryable_even_though_a_later_attempt_might_work() {
373 let conflict = SearchError::VersionConflict {
376 index: "posts".into(),
377 id: "1".into(),
378 reason: "version conflict".into(),
379 };
380
381 assert!(!conflict.is_retryable());
382 assert!(conflict.is_conflict());
383 }
384
385 #[test]
386 fn a_version_conflict_names_the_document() {
387 let body = r#"{"error":{"root_cause":[{"type":"version_conflict_engine_exception","reason":"[1]: version conflict, document already exists (current version [1])","index_uuid":"3zLDcCFFTASdSaKQTa93Xg","shard":"0","index":"posts"}],"type":"version_conflict_engine_exception","reason":"[1]: version conflict, document already exists (current version [1])","index_uuid":"3zLDcCFFTASdSaKQTa93Xg","shard":"0","index":"posts"},"status":409}"#;
388
389 let error = SearchError::from_response(409, "posts", body);
390 match &error {
391 SearchError::VersionConflict { index, .. } => assert_eq!(index, "posts"),
392 other => panic!("got {other:?}"),
393 }
394 assert!(error.to_string().contains("Read it again"), "{error}");
395 }
396
397 #[test]
398 fn a_circuit_breaker_is_recognised_by_its_type_not_only_by_a_429() {
399 let body = r#"{"error":{"root_cause":[{"type":"circuit_breaking_exception","reason":"[parent] Data too large, data for [<http_request>] would be [1.1gb], which is larger than the limit of [1gb]","bytes_wanted":1181116006,"bytes_limit":1073741824,"durability":"TRANSIENT"}],"type":"circuit_breaking_exception","reason":"[parent] Data too large","bytes_wanted":1181116006,"bytes_limit":1073741824,"durability":"TRANSIENT"},"status":429}"#;
400
401 for status in [429, 503] {
403 let error = SearchError::from_response(status, "posts", body);
404 assert!(matches!(error, SearchError::Overloaded { .. }), "at {status}: {error:?}");
405 assert!(error.is_retryable());
406 }
407 }
408
409 #[test]
410 fn bad_credentials_and_a_missing_privilege_are_different_problems() {
411 let missing = r#"{"error":{"root_cause":[{"type":"security_exception","reason":"missing authentication credentials for REST request [/posts/_search]","header":{"WWW-Authenticate":["Basic realm=\"security\" charset=\"UTF-8\""]}}],"type":"security_exception","reason":"missing authentication credentials for REST request [/posts/_search]"},"status":401}"#;
412 let denied = r#"{"error":{"root_cause":[{"type":"security_exception","reason":"action [indices:data/read/search] is unauthorized for user [reader] on indices [secret]"}],"type":"security_exception","reason":"action [indices:data/read/search] is unauthorized for user [reader] on indices [secret]"},"status":403}"#;
413
414 assert!(matches!(
415 SearchError::from_response(401, "posts", missing),
416 SearchError::Unauthorized { .. }
417 ));
418 assert!(matches!(
419 SearchError::from_response(403, "secret", denied),
420 SearchError::Forbidden { .. }
421 ));
422 assert!(SearchError::from_response(403, "secret", denied).is_auth_failure());
423 }
424
425 #[test]
426 fn a_creating_an_index_that_exists_is_its_own_case() {
427 let body = r#"{"error":{"root_cause":[{"type":"resource_already_exists_exception","reason":"index [posts/9jNXbCyMS4WTRDMCsQiSuQ] already exists","index_uuid":"9jNXbCyMS4WTRDMCsQiSuQ","index":"posts"}],"type":"resource_already_exists_exception","reason":"index [posts/9jNXbCyMS4WTRDMCsQiSuQ] already exists","index_uuid":"9jNXbCyMS4WTRDMCsQiSuQ","index":"posts"},"status":400}"#;
428
429 assert_eq!(
430 SearchError::from_response(400, "posts", body),
431 SearchError::IndexAlreadyExists { index: "posts".into() }
432 );
433 }
434
435 #[test]
436 fn a_broken_query_is_a_bad_request_and_not_a_mapping_error() {
437 let body = r#"{"error":{"root_cause":[{"type":"parsing_exception","reason":"unknown query [matchh]","line":1,"col":30}],"type":"parsing_exception","reason":"unknown query [matchh]","line":1,"col":30},"status":400}"#;
440
441 let error = SearchError::from_response(400, "posts", body);
442 assert!(matches!(error, SearchError::BadRequest { .. }), "got {error:?}");
443 assert_eq!(error.kind(), "parsing_exception");
444 }
445
446 #[test]
447 fn a_body_that_is_not_the_expected_envelope_is_shown_rather_than_swallowed() {
448 let error = SearchError::from_response(502, "posts", "<html>502 Bad Gateway</html>");
449 assert!(error.to_string().contains("Bad Gateway"), "got {error}");
450 }
451
452 #[test]
453 fn a_bare_string_error_is_read_as_the_reason() {
454 let error = SearchError::from_response(
456 405,
457 "posts",
458 r#"{"error":"Incorrect HTTP method for uri [/posts] and method [PUT]","status":405}"#,
459 );
460 assert!(error.to_string().contains("Incorrect HTTP method"), "got {error}");
461 }
462
463 #[test]
464 fn an_enormous_stack_trace_cannot_flood_a_log() {
465 let body = format!(r#"{{"error":{{"type":"x","reason":"{}"}},"status":500}}"#, "y".repeat(9000));
466 let error = SearchError::from_response(500, "posts", &body);
467 assert!(error.to_string().len() < 500, "an error must not be a log flood");
468 }
469
470 #[test]
471 fn an_empty_body_says_so_instead_of_pretending() {
472 let error = SearchError::from_response(500, "posts", "");
473 assert!(error.to_string().contains("no explanation"), "got {error}");
474 }
475}