Skip to main content

rustlavel_search/
error.rs

1//! What a cluster says when it says no.
2//!
3//! Elasticsearch answers almost every failure with the same envelope —
4//! `{"error": {"type": ..., "reason": ...}, "status": ...}` — and the status
5//! code alone is not enough to act on. A 400 is both "your query has a syntax
6//! error" and "this document does not fit the mapping"; a 404 is both "no such
7//! index" and "no such document". The two halves of each pair call for
8//! completely different responses, so the type is read as well as the code.
9//!
10//! The one distinction that costs money if it is lost: a 429 is the cluster
11//! protecting itself and is worth retrying after a pause, while a mapping
12//! error will fail identically forever. [`SearchError::is_retryable`] is the
13//! only place that judgement is made.
14
15use rustlavel_core::{Error, Json};
16
17/// The crate's result type.
18///
19/// Deliberately not `rustlavel_core::Result`: telling a version conflict from
20/// a mapping error is the entire point of this module, and flattening
21/// everything into one message string would take that away from callers.
22/// [`SearchError`] converts into the framework error with `?` when it reaches
23/// a handler.
24pub type Result<T> = std::result::Result<T, SearchError>;
25
26/// A failure from Elasticsearch or OpenSearch.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum SearchError {
29    /// The index does not exist.
30    ///
31    /// Usually a typo or a missing bootstrap step rather than an outage, and
32    /// worth telling apart from an empty result: searching a misspelled index
33    /// and searching an index with nothing in it look identical from the
34    /// caller's side otherwise.
35    IndexNotFound { index: String },
36    /// The index already exists, from a create that assumed it did not.
37    ///
38    /// Ordinary in a startup path that creates its indices every boot, which
39    /// is why [`crate::SearchClient::create_index_if_missing`] exists.
40    IndexAlreadyExists { index: String },
41    /// The document does not fit the mapping.
42    ///
43    /// A string where the mapping says `long`, a field the index refuses under
44    /// `dynamic: strict`, an unparseable date. Never retryable — the same
45    /// bytes will be rejected the same way for as long as the mapping stands.
46    /// The reason carries the underlying cause where the cluster gives one,
47    /// because `failed to parse field [price]` without it names the field but
48    /// not the problem.
49    MapperParsing { kind: String, reason: String },
50    /// The document changed since it was read.
51    ///
52    /// Optimistic concurrency working as intended: something else wrote this
53    /// document first. Retrying the identical request cannot help, but reading
54    /// the document again and reapplying the change usually can — which is why
55    /// this is not folded into [`SearchError::is_retryable`].
56    VersionConflict { index: String, id: String, reason: String },
57    /// The cluster refused the work to stay alive.
58    ///
59    /// A circuit breaker tripping, a full search or write queue, an index
60    /// blocked by a disk watermark. The request was not performed, so backing
61    /// off and repeating it is safe and is usually the correct response.
62    Overloaded { kind: String, reason: String },
63    /// 401 — no credentials, or credentials the cluster does not accept.
64    Unauthorized { reason: String },
65    /// 403 — authenticated, but not allowed to do this.
66    ///
67    /// Separate from [`SearchError::Unauthorized`] because the fixes have
68    /// nothing in common: one is a wrong password, the other a missing role.
69    Forbidden { reason: String },
70    /// A request the cluster could parse but not accept — a malformed query,
71    /// an illegal argument, an unknown parameter.
72    BadRequest { kind: String, reason: String },
73    /// The cluster is not answering: a gateway in front of it, a node
74    /// restarting, a shard with no copy available.
75    Unavailable { status: u16, reason: String },
76    /// Some documents in a `_bulk` request were not written.
77    ///
78    /// The one failure the HTTP status will never tell you about: `_bulk`
79    /// answers 200 whether every item succeeded or none did, and reports the
80    /// per-item outcome in the body. This exists so that
81    /// [`crate::BulkReport::ok_or_error`] can turn that into an error a `?`
82    /// notices, for the callers that would rather not inspect every item.
83    /// Which items, and why, is in the report — the reasons differ per item,
84    /// so retryability cannot be decided for the request as a whole.
85    BulkPartialFailure { failed: usize, total: usize, first: String },
86    /// Anything else the cluster answered.
87    Unexpected { status: u16, kind: String, reason: String },
88    /// The request never reached the cluster.
89    Transport(String),
90    /// The cluster answered successfully with a shape this crate cannot read.
91    ///
92    /// Distinct from every variant above, which are the cluster saying no.
93    /// This one is a yes in a form we did not expect — a proxy rewriting the
94    /// body, or a version whose response layout moved — and naming what was
95    /// being parsed is the only thing that makes it diagnosable.
96    Malformed { context: String, message: String },
97}
98
99impl SearchError {
100    /// Build from a status code and whatever body came with it.
101    ///
102    /// `context` names what was being attempted; it is used only where the
103    /// body does not identify the resource itself, which is common for a
104    /// document-level failure.
105    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        // The type is consulted before the status, because the interesting
118        // pairs share a code: `mapper_parsing_exception` and a syntactically
119        // broken query are both 400, and `index_not_found_exception` and a
120        // missing document are both 404.
121        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            // A circuit breaker can arrive as a 503 during a node restart as
138            // well as the usual 429, so the type decides rather than the code.
139            "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    /// Whether repeating the identical request could plausibly succeed.
158    ///
159    /// The judgement callers most need and most often get wrong. A rejected
160    /// write never happened, so repeating it after a pause is both safe and
161    /// likely to work. A mapping error, a bad query and a permission failure
162    /// will fail the same way for as long as nothing else changes, and
163    /// retrying them only turns one clear log line into a hundred.
164    ///
165    /// A version conflict is deliberately *not* retryable even though a later
166    /// attempt may well succeed: the request that lost carries a stale
167    /// document, and sending it again would overwrite the winner.
168    pub fn is_retryable(&self) -> bool {
169        matches!(
170            self,
171            SearchError::Overloaded { .. }
172                | SearchError::Unavailable { .. }
173                | SearchError::Transport(_)
174        )
175    }
176
177    /// Whether re-reading the document and reapplying the change could work.
178    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    /// Whether the failure is about credentials rather than the request.
187    pub fn is_auth_failure(&self) -> bool {
188        matches!(self, SearchError::Unauthorized { .. } | SearchError::Forbidden { .. })
189    }
190
191    /// The cluster's own `error.type`, where it gave one.
192    ///
193    /// Worth surfacing: Elasticsearch has hundreds of exception types and this
194    /// crate names only the handful worth branching on, so a caller chasing an
195    /// unusual failure still has the string the documentation indexes by.
196    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
210/// Pull the human explanation out of the envelope.
211///
212/// `caused_by.reason` is appended where it says something new, because it is
213/// routinely the only useful half: "failed to parse field [price] of type
214/// [long]" names the field, and the cause names what was actually in it.
215fn reason_of(error: Option<&Json>, body: &str) -> String {
216    let Some(error) = error else {
217        return fallback(body);
218    };
219
220    // Some failures — a wrong HTTP method, a few security responses — put a
221    // bare string where the object normally goes.
222    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
238/// Show the body when it is not the shape we expect.
239///
240/// A proxy answering with HTML in front of the cluster is exactly the case
241/// where the raw body explains everything and "unknown error" explains nothing.
242fn 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
251/// Cap a reason so one error cannot flood a log.
252///
253/// Elasticsearch stack traces reach kilobytes, and `error_trace=true` makes
254/// them longer still.
255fn 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    /// Bodies in these tests are copied from a running Elasticsearch 8.15,
337    /// not written from memory.
338    #[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        // The distinction the whole module exists for: one of these will fail
363        // identically forever, the other was never performed at all.
364        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        // Retrying means resending the stale document, which would overwrite
374        // whoever won the race. The caller has to re-read first.
375        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        // The same breaker arrives as a 503 while a node is restarting.
402        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        // Both are 400. Confusing them would tell somebody to change their
438        // mapping when the query is what is wrong.
439        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        // Some responses put a string where the object normally goes.
455        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}