Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.11% covered (danger)
36.11%
39 / 108
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ElasticaErrorHandler
36.11% covered (danger)
36.11%
39 / 108
0.00% covered (danger)
0.00%
0 / 7
597.81
0.00% covered (danger)
0.00%
0 / 1
 logRequestResponse
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 extractMessage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 extractFullError
46.43% covered (danger)
46.43%
13 / 28
0.00% covered (danger)
0.00%
0 / 1
34.14
 classifyError
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
10.03
 isParseError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 extractMessageAndStatus
12.82% covered (danger)
12.82%
5 / 39
0.00% covered (danger)
0.00%
0 / 1
107.41
 formatMessage
46.67% covered (danger)
46.67%
7 / 15
0.00% covered (danger)
0.00%
0 / 1
11.46
1<?php
2
3namespace CirrusSearch;
4
5use Elastica\Exception\Bulk\ResponseException as BulkResponseException;
6use Elastica\Exception\PartialShardFailureException;
7use Elastica\Exception\ResponseException;
8use MediaWiki\Logger\LoggerFactory;
9use Status;
10
11/**
12 * Generic functions for extracting and reporting on errors/exceptions
13 * from Elastica.
14 */
15class ElasticaErrorHandler {
16
17    public static function logRequestResponse( Connection $conn, $message, array $context = [] ) {
18        $client = $conn->getClient();
19        LoggerFactory::getInstance( 'CirrusSearch' )->info( $message, $context + [
20            'cluster' => $conn->getClusterName(),
21            'elasticsearch_request' => (string)$client->getLastRequest(),
22            'elasticsearch_response' => $client->getLastResponse() !== null ? json_encode( $client->getLastResponse()->getData() ) : "NULL",
23        ] );
24    }
25
26    /**
27     * @param \Elastica\Exception\ExceptionInterface $exception
28     * @return string
29     */
30    public static function extractMessage( \Elastica\Exception\ExceptionInterface $exception ) {
31        $error = self::extractFullError( $exception );
32        return self::formatMessage( $error );
33    }
34
35    /**
36     * Extract an error message from an exception thrown by Elastica.
37     * @param \Elastica\Exception\ExceptionInterface $exception exception from which to extract a message
38     * @return array structuerd error from the exception
39     * @suppress PhanUndeclaredMethod ExceptionInterface doesn't declare any methods
40     *  so we have to suppress those warnings.
41     */
42    public static function extractFullError( \Elastica\Exception\ExceptionInterface $exception ): array {
43        if ( $exception instanceof BulkResponseException ) {
44            $actionReasons = [];
45            foreach ( $exception->getActionExceptions() as $actionException ) {
46                $actionReasons[] = $actionException->getMessage() . ': '
47                    . self::formatMessage( $actionException->getResponse()->getFullError() );
48            }
49            return [
50                'type' => 'bulk',
51                'reason' => $exception->getMessage(),
52                'actionReasons' => $actionReasons,
53            ];
54        } elseif ( !( $exception instanceof ResponseException ) ) {
55            // simulate the basic full error structure
56            return [
57                'type' => 'unknown',
58                'reason' => $exception->getMessage()
59            ];
60        }
61        if ( $exception instanceof PartialShardFailureException ) {
62            // @todo still needs to be fixed, need a way to trigger this
63            // failure
64            $shardStats = $exception->getResponse()->getShardsStatistics();
65            $message = [];
66            $type = null;
67            foreach ( $shardStats[ 'failures' ] as $failure ) {
68                $message[] = $failure['reason']['reason'];
69                if ( $type === null ) {
70                    $type = $failure['reason']['type'];
71                }
72            }
73
74            return [
75                'type' => $type,
76                'reason' => 'Partial failure:  ' . implode( ',', $message ),
77                'partial' => true
78            ];
79        }
80
81        $response = $exception->getResponse();
82        $error = $response->getFullError();
83        if ( is_string( $error ) ) {
84            $error = [
85                'type' => 'unknown',
86                'reason' => $error,
87            ];
88        } elseif ( $error === null ) {
89            // response wasnt json or didn't contain 'error' key
90            // in this case elastica reports nothing.
91            $data = $response->getData();
92            $reason = 'Status code ' . $response->getStatus();
93            if ( isset( $data['message'] ) ) {
94                // Client puts non-json responses here
95                $reason .= "; " . substr( $data['message'], 0, 200 );
96            } elseif ( is_string( $data ) && $data !== "" ) {
97                // pre-6.0.3 versions of Elastica
98                $reason .= "; " . substr( $data, 0, 200 );
99            }
100
101            $error = [
102                'type' => 'unknown',
103                'reason' => $reason,
104            ];
105        }
106
107        return $error;
108    }
109
110    /**
111     * Broadly classify the error message into failures where
112     * we decided to not serve the query, and failures where
113     * we just failed to answer
114     *
115     * @param \Elastica\Exception\ExceptionInterface|null $exception
116     * @return string Either 'rejected', 'failed' or 'unknown'
117     */
118    public static function classifyError( \Elastica\Exception\ExceptionInterface $exception = null ) {
119        if ( $exception === null ) {
120            return 'unknown';
121        }
122        $error = self::extractFullError( $exception );
123        if ( isset( $error['root_cause'][0]['type'] ) ) {
124            $error = reset( $error['root_cause'] );
125        } elseif ( !( isset( $error['type'] ) && isset( $error['reason'] ) ) ) {
126            return 'unknown';
127        }
128
129        $heuristics = [
130            'rejected' => [
131                'type_regexes' => [
132                    '(^|_)regex_',
133                    '^too_complex_to_determinize_exception$',
134                    '^elasticsearch_parse_exception$',
135                    '^search_parse_exception$',
136                    '^query_shard_exception$',
137                    '^illegal_argument_exception$',
138                    '^too_many_clauses$',
139                    '^parsing_exception$',
140                    '^parse_exception$',
141                    '^script_exception$',
142                ],
143                'msg_regexes' => [
144                ],
145            ],
146            'failed' => [
147                'type_regexes' => [
148                    '^es_rejected_execution_exception$',
149                    '^remote_transport_exception$',
150                    '^search_context_missing_exception$',
151                    '^null_pointer_exception$',
152                    '^elasticsearch_timeout_exception$',
153                    '^retry_on_primary_exception$',
154                    '^circuit_breaking_exception$',
155                ],
156                // These are exceptions thrown by elastica itself
157                'msg_regexes' => [
158                    '^Couldn\'t connect to host',
159                    '^No enabled connection',
160                    '^Operation timed out',
161                    '^Status code 503',
162                ],
163            ],
164            'config_issue' => [
165                'type_regexes' => [
166                    '^index_not_found_exception$',
167                ],
168                'msg_regexes' => [
169                    // for 'bulk' errors index_not_found_exception is set
170                    // in message and not type
171                    'index_not_found_exception',
172                ],
173            ],
174            'memory_issue' => [
175                'type_regexes' => [
176                    '^circuit_breaking_exception$',
177                ],
178                'msg_regexes' => [],
179            ],
180        ];
181
182        foreach ( $heuristics as $type => $heuristic ) {
183            $regex = implode( '|', $heuristic['type_regexes'] );
184            if ( $regex && preg_match( "/$regex/", $error['type'] ) ) {
185                return $type;
186            }
187            $regex = implode( '|', $heuristic['msg_regexes'] );
188            if ( $regex && preg_match( "/$regex/", $error['reason'] ) ) {
189                return $type;
190            }
191        }
192        return "unknown";
193    }
194
195    /**
196     * Does this status represent an Elasticsearch parse error?
197     * @param Status $status Status to check
198     * @return bool is this a parse error?
199     */
200    public static function isParseError( Status $status ) {
201        /** @todo No good replacements for getErrorsArray */
202        foreach ( $status->getErrorsArray() as $errorMessage ) {
203            if ( $errorMessage[ 0 ] === 'cirrussearch-parse-error' ) {
204                return true;
205            }
206        }
207        return false;
208    }
209
210    /**
211     * @param \Elastica\Exception\ExceptionInterface|null $exception
212     * @return array Two elements, first is Status object, second is string.
213     */
214    public static function extractMessageAndStatus( \Elastica\Exception\ExceptionInterface $exception = null ) {
215        if ( !$exception ) {
216            return [ Status::newFatal( 'cirrussearch-backend-error' ), '' ];
217        }
218
219        // Lots of times these are the same as getFullError(), but sometimes
220        // they're not. I'm looking at you PartialShardFailureException.
221        $error = self::extractFullError( $exception );
222
223        // These can be top level errors, or exceptions that don't extend from
224        // ResponseException like PartialShardFailureException or errors
225        // contacting the cluster.
226        if ( !isset( $error['root_cause'][0]['type'] ) ) {
227            return [
228                Status::newFatal( 'cirrussearch-backend-error' ),
229                self::formatMessage( $error )
230            ];
231        }
232
233        // We can have multiple root causes if the error is not the
234        // same on different shards. Errors will be deduplicated based
235        // on their type. Currently we display only the first one if
236        // it happens.
237        $cause = reset( $error['root_cause'] );
238
239        if ( $cause['type'] === 'query_shard_exception' ) {
240            // The important part of the parse error message is embedded a few levels down
241            // and comes before the next new line so lets slurp it up and log it rather than
242            // the huge clump of error.
243            $shardFailure = reset( $error['failed_shards'] );
244            if ( !empty( $shardFailure['reason'] ) ) {
245                if ( !empty( $shardFailure['reason']['caused_by'] ) ) {
246                    $message = $shardFailure['reason']['caused_by']['reason'];
247                } else {
248                    $message = $shardFailure['reason']['reason'];
249                }
250            } else {
251                $message = "???";
252            }
253            $end = strpos( $message, "\n", 0 );
254            if ( $end === false ) {
255                $end = strlen( $message );
256            }
257            $parseError = substr( $message, 0, $end );
258
259            return [
260                Status::newFatal( 'cirrussearch-parse-error' ),
261                'Parse error on ' . $parseError
262            ];
263        }
264
265        if ( $cause['type'] === 'too_complex_to_determinize_exception' ) {
266            return [ Status::newFatal(
267                'cirrussearch-regex-too-complex-error' ),
268                $cause['reason']
269            ];
270        }
271
272        if ( $cause['type'] === 'script_exception' ) {
273            // do not use $cause which won't contain the caused_by chain
274            $formattedMessage = self::formatMessage( $error['caused_by'] );
275            $formattedMessage .= "\n\t" . implode( "\n\t", $cause['script_stack'] ) . "\n";
276            return [
277                Status::newFatal( 'cirrussearch-backend-error' ),
278                $formattedMessage
279            ];
280        }
281
282        if ( preg_match( '/(^|_)regex_/', $cause['type'] ) ) {
283            $syntaxError = $cause['reason'];
284            $errorMessage = 'unknown';
285            $position = 'unknown';
286            // Note: we support only error coming from the extra plugin
287            // In the case Cirrus is installed without the plugin and
288            // is using the Groovy script to do regex then a generic backend error
289            // will be displayed.
290
291            $matches = [];
292            // In some cases elastic will serialize the exception by adding
293            // an extra message prefix with the exception type.
294            // If the exception is serialized through Transport:
295            // invalid_regex_exception: expected ']' at position 2
296            // Or if the exception is thrown locally by the node receiving the query:
297            // expected ']' at position 2
298            if ( preg_match( '/(?:[a-z_]+: )?(.+) at position (\d+)/', $syntaxError, $matches ) ) {
299                list( , $errorMessage, $position ) = $matches;
300            } elseif ( $syntaxError === 'unexpected end-of-string' ) {
301                $errorMessage = 'regex too short to be correct';
302            }
303            $status = Status::newFatal( 'cirrussearch-regex-syntax-error', $errorMessage, $position );
304
305            return [ $status, 'Regex syntax error:  ' . $syntaxError ];
306        }
307
308        return [
309            Status::newFatal( 'cirrussearch-backend-error' ),
310            self::formatMessage( $cause )
311        ];
312    }
313
314    /**
315     * Takes an error and converts it into a useful message. Mostly this is to deal with
316     * errors where the useful part is hidden inside a caused_by chain.
317     * WARNING: In some circumstances, like bulk update failures, this could be multiple
318     * megabytes.
319     *
320     * @param array $error An error array, such as the one returned by extractFullError().
321     * @return string
322     */
323    protected static function formatMessage( array $error ) {
324        if ( isset( $error['actionReasons'] ) ) {
325            $message = $error['type'] . ': ' . $error['reason'];
326            foreach ( $error['actionReasons'] as $actionReason ) {
327                $message .= "  - $actionReason\n";
328            }
329            return $message;
330        }
331
332        $causeChain = [];
333        $errorCursor = $error;
334        while ( isset( $errorCursor['caused_by'] ) ) {
335            $errorCursor = $errorCursor['caused_by'];
336            if ( $errorCursor['reason'] ) {
337                $causeChain[] = $errorCursor['reason'];
338            }
339        }
340        $message = $error['type'] . ': ' . $error['reason'];
341        if ( $causeChain ) {
342            $message .= ' (' . implode( ' -> ', array_reverse( $causeChain ) ) . ')';
343        }
344        return $message;
345    }
346
347}